feat: добавить серверную генерацию спрайтов

This commit is contained in:
2026-07-16 09:14:11 +03:00
parent e9ae91815a
commit 57342fae4e
379 changed files with 3108 additions and 655 deletions

View File

@@ -1,5 +1,5 @@
import { isSpriteMode } from '../config.js'
import type { SpriteConfig, TransformOptions } from '../types.js'
import type { SpriteConfig, SpriteSource, TransformOptions } from '../types.js'
import type { CliArgs } from './types.js'
export const CLI_USAGE = [
@@ -14,6 +14,7 @@ export const CLI_USAGE = [
' standalone',
' standalone@vite',
' standalone@webpack',
' standalone@server',
' react@vite',
' react@webpack',
' vue@vite',
@@ -43,6 +44,7 @@ export const CLI_USAGE = [
'',
'Options:',
' --mode <mode>',
' --source <local|remote>',
' --name <name>',
' --description <text>',
' --input <path-or-glob> Repeat for multiple inputs',
@@ -66,8 +68,18 @@ function optionValue(argv: string[], index: number, option: string): [string, nu
return [value, index + 1]
}
type MutableCliOverrides = {
mode?: SpriteConfig['mode']
source?: SpriteSource
name?: string
description?: string
input?: string | string[]
transform?: TransformOptions
generatedNotice?: boolean
}
function setTransform(
overrides: SpriteConfig,
overrides: MutableCliOverrides,
option: keyof TransformOptions,
value: boolean,
): void {
@@ -81,7 +93,7 @@ export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
if (argv.includes('--help') || argv.includes('-h')) return { help: true }
const positional: string[] = []
const overrides: SpriteConfig = {}
const overrides: MutableCliOverrides = {}
for (let index = 0; index < argv.length; index++) {
const argument = argv[index]
@@ -93,6 +105,15 @@ export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
index = nextIndex
continue
}
if (argument === '--source' || argument.startsWith('--source=')) {
const [value, nextIndex] = optionValue(argv, index, '--source')
if (value !== 'local' && value !== 'remote') {
throw new Error(`Unsupported sprite source: ${value}. Expected local or remote.`)
}
overrides.source = value
index = nextIndex
continue
}
if (argument === '--name' || argument.startsWith('--name=')) {
const [value, nextIndex] = optionValue(argv, index, '--name')
overrides.name = value
@@ -157,6 +178,6 @@ export function parseCliArgs(argv: string[]): CliArgs | { help: true } {
return {
path: positional[0],
overrides,
overrides: overrides as SpriteConfig,
}
}

View File

@@ -10,6 +10,11 @@ export type CompileSpriteOptions = {
rootViewBox?: boolean
}
export type SpriteSourceContent = {
readonly name: string
readonly content: string
}
/** Конфигурация режима для svg-sprite. */
function getModeConfig(
format: SpriteFormat,
@@ -74,6 +79,21 @@ export async function compileSpriteContent(
folder: SpriteFolder,
transform: TransformOptions = {},
options: CompileSpriteOptions = {},
): Promise<Uint8Array> {
const sources = folder.files.map((filePath) => ({
name: path.basename(filePath, '.svg'),
content: fs.readFileSync(filePath, 'utf-8'),
}))
return compileSpriteSourceContent(folder.name, folder.format, sources, transform, options)
}
/** Компилирует SVG sources из памяти без промежуточных файлов. */
export async function compileSpriteSourceContent(
name: string,
format: SpriteFormat,
sources: readonly SpriteSourceContent[],
transform: TransformOptions = {},
options: CompileSpriteOptions = {},
): Promise<Uint8Array> {
const config = {
shape: {
@@ -86,14 +106,14 @@ export async function compileSpriteContent(
transform: buildShapeTransforms(transform),
},
mode: {
[folder.format]: getModeConfig(folder.format, '.', folder.name, options),
[format]: getModeConfig(format, '.', name, options),
},
}
const spriter = new SVGSpriter(config)
for (const filePath of folder.files) {
spriter.add(filePath, null, fs.readFileSync(filePath, 'utf-8'))
for (const source of sources) {
spriter.add(`${source.name}.svg`, null, source.content)
}
return new Promise((resolve, reject) => {
@@ -116,7 +136,7 @@ export async function compileSpriteContent(
}
if (!spriteContents) {
reject(new Error(`Failed to compile sprite "${folder.name}".`))
reject(new Error(`Failed to compile sprite "${name}".`))
return
}

View File

@@ -3,11 +3,19 @@ import path from 'node:path'
import { createJiti } from 'jiti'
import { toKebabCase, validateSpriteName } from './core/naming.js'
import type { SpriteMode } from './targets/types.js'
import type { ResolvedSpriteConfig, SpriteConfig, TransformOptions } from './types.js'
import type {
ResolvedSpriteConfig,
ServerSvgInput,
SpriteConfig,
SpriteInput,
SpriteSource,
TransformOptions,
} from './types.js'
const CONFIG_EXTENSIONS = new Set(['.js', '.json', '.ts'])
const CONFIG_FIELDS = new Set([
'mode',
'source',
'name',
'description',
'input',
@@ -19,6 +27,7 @@ const MODES = new Set<SpriteMode>([
'standalone',
'standalone@vite',
'standalone@webpack',
'standalone@server',
'react@vite',
'react@webpack',
'vue@vite',
@@ -57,6 +66,52 @@ export function isSpriteMode(value: unknown): value is SpriteMode {
return typeof value === 'string' && MODES.has(value as SpriteMode)
}
function isServerSvgInput(value: unknown): value is ServerSvgInput {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const input = value as Record<string, unknown>
if (Object.keys(input).some((field) => !['name', 'url', 'sha256'].includes(field))) return false
return typeof input.name === 'string'
&& input.name.trim() !== ''
&& !/[\\/]/.test(input.name)
&& !input.name.endsWith('.svg')
&& typeof input.url === 'string'
&& input.url.trim() !== ''
&& (input.sha256 === undefined || (typeof input.sha256 === 'string' && /^[a-f0-9]{64}$/i.test(input.sha256)))
}
function inputEntries(input: unknown): unknown[] {
return Array.isArray(input) ? input : [input]
}
function validateInput(config: Record<string, unknown>): void {
if (config.input === undefined) return
const entries = inputEntries(config.input)
if (entries.length === 0) {
throw configError('"input" must be a non-empty string or an array of non-empty strings.')
}
if (config.source === 'remote') {
if (typeof config.input !== 'string' || config.input.trim() === '') {
throw configError('remote "input" must be one manifest path or HTTP(S) URL.')
}
return
}
if (config.mode === 'standalone@server') {
if (entries.some((entry) => (
typeof entry === 'string' ? entry.trim() === '' : !isServerSvgInput(entry)
))) {
throw configError('standalone@server "input" must contain local paths/globs or { name, url, sha256? } entries.')
}
return
}
if (entries.some((entry) => typeof entry !== 'string' || entry.trim() === '')) {
throw configError('"input" must be a non-empty string or an array of non-empty strings.')
}
}
function getDefaultName(rootDir: string): string {
const rootName = path.basename(rootDir)
const source = rootName === 'svg-sprite' || rootName === 'svg-sprites'
@@ -92,21 +147,24 @@ export function validateSpriteConfig(value: unknown): asserts value is SpriteCon
if (config.mode !== undefined && !isSpriteMode(config.mode)) {
throw configError(`unsupported "mode": ${String(config.mode)}.`)
}
if (config.source !== undefined && config.source !== 'local' && config.source !== 'remote') {
throw configError('"source" must be "local" or "remote".')
}
if (config.mode === 'standalone@server' && config.source === 'remote') {
throw configError('standalone@server cannot consume a remote manifest.')
}
if (config.source === 'remote') {
for (const field of ['name', 'description', 'transform', 'generatedNotice']) {
if (field in config) throw configError(`remote config does not support "${field}".`)
}
}
if (config.name !== undefined && typeof config.name !== 'string') {
throw configError('"name" must be a string.')
}
if (config.description !== undefined && typeof config.description !== 'string') {
throw configError('"description" must be a string.')
}
if (config.input !== undefined && (
typeof config.input !== 'string'
? !Array.isArray(config.input)
|| config.input.length === 0
|| config.input.some((entry) => typeof entry !== 'string' || entry.trim() === '')
: config.input.trim() === ''
)) {
throw configError('"input" must be a non-empty string or an array of non-empty strings.')
}
validateInput(config)
if (config.transform !== undefined) {
if (
config.transform === null
@@ -210,27 +268,38 @@ export function resolveSpriteConfig(
validateSpriteConfig(config)
validateSpriteConfig(overrides)
const configValues = definedEntries(config)
const overrideValues = definedEntries(overrides)
const transform: TransformOptions = {
...config.transform,
...overrides.transform,
const configValues = definedEntries(config) as Record<string, unknown>
const overrideValues = definedEntries(overrides) as Record<string, unknown>
const source = (overrideValues.source ?? configValues.source ?? 'local') as SpriteSource
const sourceChangedByOverride = overrideValues.source !== undefined && overrideValues.source !== configValues.source
if (sourceChangedByOverride && overrideValues.input === undefined) {
throw new Error('Changing "source" through overrides also requires an "input" override.')
}
const merged: SpriteConfig = {
const transform: TransformOptions = {
...(source === 'remote' ? {} : config.transform),
...(source === 'remote' ? {} : overrides.transform),
}
const merged = {
...configValues,
...overrideValues,
source,
transform,
}
} as Record<string, unknown>
if (!merged.mode) {
if (!merged.mode || !isSpriteMode(merged.mode)) {
throw new Error('Sprite mode is required. Set "mode" in the config or pass it through CLI/API.')
}
if (source === 'remote' && merged.mode === 'standalone@server') {
throw new Error('Mode "standalone@server" cannot consume a remote manifest.')
}
const name = merged.name ?? getDefaultName(rootDir)
const name = source === 'remote' ? getDefaultName(rootDir) : (merged.name as string | undefined) ?? getDefaultName(rootDir)
validateSpriteName(name)
const configuredInput = merged.input ?? 'icons'
const configuredInput = (merged.input ?? 'icons') as SpriteInput | SpriteInput[]
const input = (Array.isArray(configuredInput) ? configuredInput : [configuredInput])
.map((entry) => {
if (typeof entry !== 'string') return entry
if (source === 'remote' && /^https?:\/\//i.test(entry)) return entry
const negated = entry.startsWith('!')
const resolved = path.resolve(rootDir, negated ? entry.slice(1) : entry)
const normalized = path.sep === '/' ? resolved : resolved.replaceAll(path.sep, '/')
@@ -239,14 +308,15 @@ export function resolveSpriteConfig(
return {
mode: merged.mode,
source,
name,
description: merged.description,
description: source === 'remote' ? undefined : merged.description as string | undefined,
input,
transform: {
removeSize: transform.removeSize ?? true,
replaceColors: transform.replaceColors ?? true,
addTransition: transform.addTransition ?? true,
},
generatedNotice: merged.generatedNotice ?? true,
generatedNotice: source === 'remote' ? true : (merged.generatedNotice as boolean | undefined) ?? true,
}
}

View File

@@ -8,17 +8,36 @@ import type {
SpriteAssetTarget,
} from '../targets/types.js'
import type { ResolvedSpriteConfig, SpriteFolder } from '../types.js'
import type { SpriteSourceContent } from '../compiler.js'
import type { ServerSpriteManifest } from '../release/types.js'
export type GeneratedFile = {
readonly path: string
readonly content: string | Uint8Array
}
export type PreparedSprite = {
export type PreparedLocalSprite = {
readonly kind: 'local'
readonly folder: SpriteFolder
readonly iconNames: readonly string[]
}
export type PreparedContentSprite = {
readonly kind: 'content'
readonly name: string
readonly sources: readonly SpriteSourceContent[]
readonly iconNames: readonly string[]
}
export type PreparedRemoteSprite = {
readonly kind: 'remote'
readonly manifest: ServerSpriteManifest
readonly manifestLocation: string
readonly iconNames: readonly string[]
}
export type PreparedSprite = PreparedLocalSprite | PreparedContentSprite | PreparedRemoteSprite
export type ModeAdapterContext = {
readonly rootDir: string
readonly config: ResolvedSpriteConfig
@@ -60,5 +79,6 @@ export type OutputPlan = {
export interface ModeAdapter<M extends SpriteMode = SpriteMode> {
readonly mode: M
prepare?(config: ResolvedSpriteConfig): Promise<PreparedSprite>
generate(context: ModeAdapterContext): Promise<OutputPlan>
}

View File

@@ -4,7 +4,7 @@ import { getSpriteShapeId } from '../shape-id.js'
import type { ResolvedSpriteConfig } from '../types.js'
import type { PreparedSprite } from './mode-adapter.js'
function validateIconIds(iconNames: readonly string[]): void {
export function validateIconIds(iconNames: readonly string[]): void {
const namesById = new Map<string, string>()
for (const iconName of iconNames) {
@@ -23,15 +23,21 @@ function validateIconIds(iconNames: readonly string[]): void {
/** Подготавливает mode-neutral набор исходников. */
export function prepareSprite(config: ResolvedSpriteConfig): PreparedSprite {
if (config.source !== 'local') {
throw new Error('Local sprite preparation requires source "local".')
}
if (config.input.some((entry) => typeof entry !== 'string')) {
throw new Error(`Mode "${config.mode}" only accepts local path or glob inputs.`)
}
const folder = resolveSpriteSources({
name: config.name,
format: 'stack',
input: config.input,
input: config.input as string[],
})
const iconNames = folder.files
.map((filePath) => path.basename(filePath, '.svg'))
.sort()
validateIconIds(iconNames)
return { folder, iconNames }
return { kind: 'local', folder, iconNames }
}

View File

@@ -0,0 +1,25 @@
import { compileSpriteContent, type CompileSpriteOptions } from '../compiler.js'
import { loadRemoteSpriteArtifact } from '../release/load.js'
import type { SpriteFolder, TransformOptions } from '../types.js'
import { createCompiledSpriteArtifact, type CompiledSpriteArtifact } from './compiled-artifact.js'
import type { PreparedSprite } from './mode-adapter.js'
/** Компилирует local source либо выбирает готовый profile server release. */
export async function resolveCompiledSpriteArtifact(
prepared: PreparedSprite,
transform: TransformOptions,
options: CompileSpriteOptions,
): Promise<CompiledSpriteArtifact> {
if (prepared.kind === 'remote') {
return loadRemoteSpriteArtifact(
prepared,
options.rootViewBox ? 'stack-root-viewbox' : 'stack',
)
}
if (prepared.kind !== 'local' && !('folder' in prepared)) {
throw new Error('This mode cannot compile in-memory server inputs.')
}
const folder = prepared.folder as SpriteFolder
const bytes = await compileSpriteContent(folder, transform, options)
return createCompiledSpriteArtifact(bytes, prepared.iconNames, 'stack')
}

View File

@@ -6,6 +6,7 @@ import {
import type { ModeResultMetadata } from './core/mode-adapter.js'
import { writeOutputPlan } from './core/output-writer.js'
import { prepareSprite } from './core/prepare-sprite.js'
import { prepareRemoteSprite } from './release/load.js'
import type { SpriteGenerationBaseResult } from './core/result.js'
import { log } from './logger.js'
import { getModeAdapter } from './mode-registry.js'
@@ -25,10 +26,23 @@ export async function generateSprite(
overrides,
)
const adapter = getModeAdapter(config.mode)
const prepared = prepareSprite(config)
const prepared = config.source === 'remote'
? await prepareRemoteSprite(config.input[0] as string)
: adapter.prepare
? await adapter.prepare(config)
: prepareSprite(config)
const materializedConfig = prepared.kind === 'remote'
? {
...config,
name: prepared.manifest.name,
description: prepared.manifest.description,
transform: prepared.manifest.transform,
generatedNotice: prepared.manifest.generatedNotice,
}
: config
const plan = await adapter.generate({
rootDir: resolvedSource.rootDir,
config,
config: materializedConfig,
prepared,
})
const plannedPaths = new Set(plan.files.map((file) => file.path.replaceAll('\\', '/')))
@@ -51,11 +65,11 @@ export async function generateSprite(
const generatedDir = path.resolve(resolvedSource.rootDir, plan.paths.generatedDir)
const iconLabel = prepared.iconNames.length === 1 ? 'icon' : 'icons'
log.success(`${config.name} · ${prepared.iconNames.length} ${iconLabel} · ${config.mode}`)
log.success(`${materializedConfig.name} · ${prepared.iconNames.length} ${iconLabel} · ${config.mode}`)
log.detail(`${path.relative(process.cwd(), generatedDir)}`)
return {
name: config.name,
name: materializedConfig.name,
rootDir: resolvedSource.rootDir,
generatedDir,
spritePath: path.resolve(resolvedSource.rootDir, plan.paths.sprite),

View File

@@ -13,8 +13,8 @@ export { generateSprite } from './generate.js'
export type { SpriteGenerationResult } from './generate.js'
export { generateNextSprite } from './api/next.js'
export { generateReactSprite } from './api/react.js'
export { compileSprite, compileSpriteContent } from './compiler.js'
export type { CompileSpriteOptions } from './compiler.js'
export { compileSprite, compileSpriteContent, compileSpriteSourceContent } from './compiler.js'
export type { CompileSpriteOptions, SpriteSourceContent } from './compiler.js'
export { createShapeTransform } from './transforms.js'
export type {
@@ -34,6 +34,8 @@ export type {
SolidSpriteMode,
StandaloneAssetTarget,
StandaloneSpriteMode,
ServerAssetTarget,
ServerSpriteMode,
StaticAssetTarget,
SvelteSpriteMode,
SpriteAssetTarget,
@@ -54,8 +56,15 @@ export type {
SpriteConfig,
SpriteFolder,
SpriteFormat,
SpriteSource,
ServerSvgInput,
TransformOptions,
} from './types.js'
export type {
ServerSpriteAsset,
ServerSpriteManifest,
SpriteCompileProfile,
} from './release/types.js'
export type {
NextSpriteConfig,
NextSpriteGenerationOptions,

View File

@@ -18,6 +18,7 @@ import { qwikViteAdapter } from './modes/qwik-vite/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 { standaloneServerAdapter } from './modes/standalone-server/adapter.js'
import { standaloneViteAdapter } from './modes/standalone-vite/adapter.js'
import { standaloneWebpackAdapter } from './modes/standalone-webpack/adapter.js'
import { solidStartViteAdapter } from './modes/solid-start-vite/adapter.js'
@@ -32,6 +33,7 @@ import type { SpriteMode } from './targets/types.js'
const modeRegistry: Record<SpriteMode, ModeAdapter> = {
standalone: standaloneAdapter,
'standalone@server': standaloneServerAdapter,
'standalone@vite': standaloneViteAdapter,
'standalone@webpack': standaloneWebpackAdapter,
'react@vite': reactViteAdapter,

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const alpineViteAdapter: ModeAdapter<'alpine@vite'> = {
mode: 'alpine@vite',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const alpineWebpackAdapter: ModeAdapter<'alpine@webpack'> = {
mode: 'alpine@webpack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,5 +1,4 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapterContext, OutputPlan } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
@@ -15,12 +14,11 @@ type AngularApplicationAdapter = {
export const angularApplicationAdapter: AngularApplicationAdapter = {
mode: 'angular@application',
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -74,11 +74,10 @@ function component(config: ResolvedSpriteConfig): string {
const ids = `${camel(config.name)}IconIds`
return [
header(config.generatedNotice),
'/// <reference path="../assets.d.ts" />',
"import { ChangeDetectionStrategy, Component, input } from '@angular/core'",
`import type { ${typeName} } from '../icon-data.js'`,
`import { ${ids} } from '../icon-data.js'`,
"import spriteUrl from '../sprite.svg'",
"import spriteManifest from '../svg-sprite.manifest.js'",
'',
'@Component({',
` selector: '${config.name}-icon',`,
@@ -91,7 +90,7 @@ function component(config: ResolvedSpriteConfig): string {
` readonly icon = input.required<${typeName}>()`,
'',
' get href(): string {',
` return spriteUrl + '#' + ${ids}[this.icon()]`,
` return spriteManifest.spriteUrl + '#' + ${ids}[this.icon()]`,
' }',
'}',
'',
@@ -138,17 +137,6 @@ function indexDeclarations(config: ResolvedSpriteConfig): string {
].join('\n')
}
function assetDeclarations(config: ResolvedSpriteConfig): string {
return [
header(config.generatedNotice),
"declare module '*.svg' {",
' const url: string',
' export default url',
'}',
'',
].join('\n')
}
function styles(config: ResolvedSpriteConfig): string {
const transition = config.transform.addTransition
? [' transition-property: fill, stroke, color;', ' transition-duration: 0.3s;', ' transition-timing-function: ease;']
@@ -229,7 +217,6 @@ export function generateOutputFiles(config: ResolvedSpriteConfig, artifact: Comp
return [
{ path: path.posix.join(OUTPUT_DIR, 'index.ts'), content: index(config) },
{ path: path.posix.join(OUTPUT_DIR, 'index.d.ts'), content: indexDeclarations(config) },
{ path: path.posix.join(OUTPUT_DIR, 'assets.d.ts'), content: assetDeclarations(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) },

View File

@@ -1,5 +1,4 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapterContext, OutputPlan } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
@@ -15,12 +14,11 @@ type AngularWebpackAdapter = {
export const angularWebpackAdapter: AngularWebpackAdapter = {
mode: 'angular@webpack',
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,5 +1,4 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapterContext, OutputPlan } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
@@ -15,12 +14,11 @@ type AstroViteAdapter = {
export const astroViteAdapter: AstroViteAdapter = {
mode: 'astro@vite',
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const litViteAdapter: ModeAdapter<'lit@vite'> = {
mode: 'lit@vite',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const litWebpackAdapter: ModeAdapter<'lit@webpack'> = {
mode: 'lit@webpack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextAppTurbopackAdapter: ModeAdapter<'next@app/turbopack'> = {
mode: 'next@app/turbopack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: true,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: true },
)
return {
files: generateOutputFiles(context.config, artifact),
paths: {

View File

@@ -1,13 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextAppWebpackAdapter: ModeAdapter<'next@app/webpack'> = {
mode: 'next@app/webpack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: true },
)
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' },

View File

@@ -1,13 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextPagesTurbopackAdapter: ModeAdapter<'next@pages/turbopack'> = {
mode: 'next@pages/turbopack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: true },
)
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' },

View File

@@ -1,13 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nextPagesWebpackAdapter: ModeAdapter<'next@pages/webpack'> = {
mode: 'next@pages/webpack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, { rootViewBox: true })
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: true },
)
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' },

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nuxtViteAdapter: ModeAdapter<'nuxt@vite'> = {
mode: 'nuxt@vite',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const nuxtWebpackAdapter: ModeAdapter<'nuxt@webpack'> = {
mode: 'nuxt@webpack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const preactViteAdapter: ModeAdapter<'preact@vite'> = {
mode: 'preact@vite',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const preactWebpackAdapter: ModeAdapter<'preact@webpack'> = {
mode: 'preact@webpack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const qwikViteAdapter: ModeAdapter<'qwik@vite'> = {
mode: 'qwik@vite',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,17 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const reactViteAdapter: ModeAdapter<'react@vite'> = {
mode: 'react@vite',
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const reactWebpackAdapter: ModeAdapter<'react@webpack'> = {
mode: 'react@webpack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),
paths: {

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const solidStartViteAdapter: ModeAdapter<'solid-start@vite'> = {
mode: 'solid-start@vite',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const solidViteAdapter: ModeAdapter<'solid@vite'> = {
mode: 'solid@vite',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const solidWebpackAdapter: ModeAdapter<'solid@webpack'> = {
mode: 'solid@webpack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -0,0 +1,43 @@
import { compileSpriteSourceContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
import { prepareStandaloneServerSprite } from './source.js'
export const standaloneServerAdapter: ModeAdapter<'standalone@server'> = {
mode: 'standalone@server',
prepare: prepareStandaloneServerSprite,
async generate(context) {
if (context.prepared.kind !== 'content') {
throw new Error('standalone@server requires prepared local and HTTP SVG content.')
}
const standardBytes = await compileSpriteSourceContent(
context.prepared.name,
'stack',
context.prepared.sources,
context.config.transform,
{ rootViewBox: false },
)
const rootViewBoxBytes = await compileSpriteSourceContent(
context.prepared.name,
'stack',
context.prepared.sources,
context.config.transform,
{ rootViewBox: true },
)
const standard = createCompiledSpriteArtifact(standardBytes, context.prepared.iconNames, 'stack')
const rootViewBox = createCompiledSpriteArtifact(rootViewBoxBytes, context.prepared.iconNames, 'stack')
const output = generateOutputFiles(context.config, standard, rootViewBox)
return {
files: output.files,
createGitignore: false,
paths: {
generatedDir: '.svg-sprite',
sprite: output.spritePath,
manifest: output.manifestPath,
},
result: { target: 'server' },
}
},
}

View File

@@ -0,0 +1,69 @@
import { createHash } from 'node:crypto'
import type { CompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import type { GeneratedFile } from '../../core/mode-adapter.js'
import type { ServerSpriteManifest, ServerSpriteAsset } from '../../release/types.js'
import type { ResolvedSpriteConfig } from '../../types.js'
const OUTPUT_DIR = '.svg-sprite'
function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex')
}
function asset(fileName: string, bytes: Uint8Array): ServerSpriteAsset {
return {
href: `./${fileName}`,
sha256: sha256(bytes),
byteLength: bytes.byteLength,
}
}
function spriteFileName(prefix: string, bytes: Uint8Array): string {
return `${prefix}.${sha256(bytes).slice(0, 16)}.svg`
}
export type StandaloneServerOutput = {
files: GeneratedFile[]
spritePath: `.svg-sprite/${string}`
manifestPath: '.svg-sprite/svg-sprite.manifest.json'
}
export function generateOutputFiles(
config: ResolvedSpriteConfig,
standard: CompiledSpriteArtifact,
rootViewBox: CompiledSpriteArtifact,
): StandaloneServerOutput {
if (JSON.stringify(standard.icons) !== JSON.stringify(rootViewBox.icons)) {
throw new Error('standalone@server sprite profiles produced different icon metadata.')
}
const standardName = spriteFileName('sprite', standard.bytes)
const rootViewBoxName = spriteFileName('sprite-root-viewbox', rootViewBox.bytes)
const manifest: ServerSpriteManifest = {
kind: '@gromlab/svg-sprites/server',
schemaVersion: 1,
generator: '@gromlab/svg-sprites',
mode: 'standalone@server',
target: 'server',
name: config.name,
...(config.description === undefined ? {} : { description: config.description }),
format: 'stack',
generatedNotice: config.generatedNotice,
transform: config.transform,
iconCount: standard.icons.length,
icons: standard.icons,
sprites: {
stack: asset(standardName, standard.bytes),
'stack-root-viewbox': asset(rootViewBoxName, rootViewBox.bytes),
},
}
const spritePath = `${OUTPUT_DIR}/${standardName}` as const
return {
files: [
{ path: spritePath, content: standard.bytes },
{ path: `${OUTPUT_DIR}/${rootViewBoxName}`, content: rootViewBox.bytes },
{ path: `${OUTPUT_DIR}/svg-sprite.manifest.json`, content: `${JSON.stringify(manifest, null, 2)}\n` },
],
spritePath,
manifestPath: '.svg-sprite/svg-sprite.manifest.json',
}
}

View File

@@ -0,0 +1,90 @@
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import type { SpriteSourceContent } from '../../compiler.js'
import { validateIconIds } from '../../core/prepare-sprite.js'
import type { PreparedContentSprite } from '../../core/mode-adapter.js'
import { resolveSpriteSources } from '../../scanner.js'
import type { ResolvedSpriteConfig, ServerSvgInput } from '../../types.js'
const MAX_SOURCE_BYTES = 2 * 1024 * 1024
const MAX_TOTAL_BYTES = 25 * 1024 * 1024
const REQUEST_TIMEOUT_MS = 15_000
function sha256(content: string | Uint8Array): string {
return createHash('sha256').update(content).digest('hex')
}
function assertSvg(content: string, name: string): void {
if (!/<svg\b/i.test(content)) throw new Error(`HTTP input "${name}" is not an SVG document.`)
if (/<!DOCTYPE|<script\b|<foreignObject\b|\son[a-z]+\s*=/i.test(content)) {
throw new Error(`HTTP input "${name}" contains unsupported active SVG content.`)
}
}
async function loadHttpInput(input: ServerSvgInput): Promise<SpriteSourceContent> {
let url: URL
try {
url = new URL(input.url)
} catch {
throw new Error(`Invalid HTTP input URL for icon "${input.name}".`)
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`HTTP input "${input.name}" must use http: or https:.`)
}
if (url.username || url.password) {
throw new Error(`HTTP input "${input.name}" must not contain URL credentials.`)
}
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
if (!response.ok) {
throw new Error(`Cannot load HTTP input "${input.name}": ${response.status} ${response.statusText}`)
}
const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > MAX_SOURCE_BYTES) {
throw new Error(`HTTP input "${input.name}" exceeds the ${MAX_SOURCE_BYTES} byte limit.`)
}
const bytes = new Uint8Array(await response.arrayBuffer())
if (bytes.byteLength > MAX_SOURCE_BYTES) {
throw new Error(`HTTP input "${input.name}" exceeds the ${MAX_SOURCE_BYTES} byte limit.`)
}
const content = new TextDecoder().decode(bytes)
assertSvg(content, input.name)
if (input.sha256 && sha256(bytes) !== input.sha256.toLowerCase()) {
throw new Error(`HTTP input "${input.name}" SHA-256 does not match the configured value.`)
}
return { name: input.name, content }
}
function loadLocalInputs(config: ResolvedSpriteConfig, input: string[]): SpriteSourceContent[] {
if (input.length === 0) return []
const folder = resolveSpriteSources({ name: config.name, format: 'stack', input })
return folder.files.map((filePath) => ({
name: path.basename(filePath, '.svg'),
content: fs.readFileSync(filePath, 'utf8'),
}))
}
export async function prepareStandaloneServerSprite(
config: ResolvedSpriteConfig,
): Promise<PreparedContentSprite> {
const localInput = config.input.filter((entry): entry is string => typeof entry === 'string')
const httpInput = config.input.filter((entry): entry is ServerSvgInput => typeof entry !== 'string')
const sources = [
...loadLocalInputs(config, localInput),
...await Promise.all(httpInput.map(loadHttpInput)),
].sort((left, right) => left.name.localeCompare(right.name))
if (sources.length === 0) throw new Error('standalone@server requires at least one SVG input.')
const totalBytes = sources.reduce((total, source) => total + Buffer.byteLength(source.content), 0)
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error(`standalone@server inputs exceed the ${MAX_TOTAL_BYTES} total byte limit.`)
}
const iconNames = sources.map((source) => source.name)
if (new Set(iconNames).size !== iconNames.length) {
throw new Error('standalone@server inputs contain duplicate icon names.')
}
validateIconIds(iconNames)
return { kind: 'content', name: config.name, sources, iconNames }
}

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-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',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-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',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const standaloneAdapter: ModeAdapter<'standalone'> = {
mode: 'standalone',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,17 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const svelteViteAdapter: ModeAdapter<'svelte@vite'> = {
mode: 'svelte@vite',
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,17 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const svelteWebpackAdapter: ModeAdapter<'svelte@webpack'> = {
mode: 'svelte@webpack',
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,17 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const sveltekitViteAdapter: ModeAdapter<'sveltekit@vite'> = {
mode: 'sveltekit@vite',
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,17 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const vueViteAdapter: ModeAdapter<'vue@vite'> = {
mode: 'vue@vite',
async generate(context) {
const bytes = await compileSpriteContent(
context.prepared.folder,
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
return {
files: generateOutputFiles(context.config, artifact),

View File

@@ -1,15 +1,15 @@
import { compileSpriteContent } from '../../compiler.js'
import { createCompiledSpriteArtifact } from '../../core/compiled-artifact.js'
import { resolveCompiledSpriteArtifact } from '../../core/resolve-compiled-artifact.js'
import type { ModeAdapter } from '../../core/mode-adapter.js'
import { generateOutputFiles } from './output.js'
export const vueWebpackAdapter: ModeAdapter<'vue@webpack'> = {
mode: 'vue@webpack',
async generate(context) {
const bytes = await compileSpriteContent(context.prepared.folder, context.config.transform, {
rootViewBox: false,
})
const artifact = createCompiledSpriteArtifact(bytes, context.prepared.iconNames, 'stack')
const artifact = await resolveCompiledSpriteArtifact(
context.prepared,
context.config.transform,
{ rootViewBox: false },
)
return {
files: generateOutputFiles(context.config, artifact),

164
src/release/load.ts Normal file
View File

@@ -0,0 +1,164 @@
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { createCompiledSpriteArtifact, type CompiledIcon, type CompiledSpriteArtifact } from '../core/compiled-artifact.js'
import type { PreparedRemoteSprite } from '../core/mode-adapter.js'
import type { SpriteCompileProfile, ServerSpriteAsset, ServerSpriteManifest } from './types.js'
const MAX_MANIFEST_BYTES = 1024 * 1024
const MAX_SPRITE_BYTES = 25 * 1024 * 1024
const REQUEST_TIMEOUT_MS = 15_000
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex')
}
function assertHash(value: unknown, field: string): asserts value is string {
if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) {
throw new Error(`Remote sprite manifest: "${field}" must be a lowercase SHA-256 hash.`)
}
}
function validateIcon(value: unknown, index: number): asserts value is CompiledIcon {
if (!isRecord(value)) throw new Error(`Remote sprite manifest: icons[${index}] must be an object.`)
if (typeof value.name !== 'string' || value.name === '') {
throw new Error(`Remote sprite manifest: icons[${index}].name must be a non-empty string.`)
}
if (typeof value.id !== 'string' || value.id === '') {
throw new Error(`Remote sprite manifest: icons[${index}].id must be a non-empty string.`)
}
if (value.viewBox !== null && typeof value.viewBox !== 'string') {
throw new Error(`Remote sprite manifest: icons[${index}].viewBox must be a string or null.`)
}
if (!Array.isArray(value.colors) || value.colors.some((color) => (
!isRecord(color)
|| typeof color.variable !== 'string'
|| typeof color.fallback !== 'string'
))) {
throw new Error(`Remote sprite manifest: icons[${index}].colors must be valid color metadata.`)
}
}
function validateAsset(value: unknown, profile: SpriteCompileProfile): asserts value is ServerSpriteAsset {
if (!isRecord(value)) throw new Error(`Remote sprite manifest: missing "${profile}" sprite profile.`)
if (typeof value.href !== 'string' || value.href === '') {
throw new Error(`Remote sprite manifest: sprites.${profile}.href must be a non-empty string.`)
}
assertHash(value.sha256, `sprites.${profile}.sha256`)
if (!Number.isSafeInteger(value.byteLength) || (value.byteLength as number) <= 0) {
throw new Error(`Remote sprite manifest: sprites.${profile}.byteLength must be a positive integer.`)
}
}
function validateTransform(value: unknown): asserts value is ServerSpriteManifest['transform'] {
if (!isRecord(value)) throw new Error('Remote sprite manifest: "transform" must be an object.')
for (const option of ['removeSize', 'replaceColors', 'addTransition']) {
if (typeof value[option] !== 'boolean') {
throw new Error(`Remote sprite manifest: transform.${option} must be a boolean.`)
}
}
}
export function validateServerSpriteManifest(value: unknown): asserts value is ServerSpriteManifest {
if (!isRecord(value)) throw new Error('Remote sprite manifest must be an object.')
if (value.kind !== '@gromlab/svg-sprites/server' || value.schemaVersion !== 1) {
throw new Error('Remote sprite manifest has an unsupported kind or schemaVersion.')
}
if (value.generator !== '@gromlab/svg-sprites' || value.mode !== 'standalone@server' || value.target !== 'server') {
throw new Error('Remote sprite manifest was not produced by standalone@server.')
}
if (typeof value.name !== 'string' || value.name === '') throw new Error('Remote sprite manifest: "name" is required.')
if (value.description !== undefined && typeof value.description !== 'string') {
throw new Error('Remote sprite manifest: "description" must be a string.')
}
if (value.format !== 'stack') throw new Error('Remote sprite manifest: unsupported sprite format.')
if (typeof value.generatedNotice !== 'boolean') throw new Error('Remote sprite manifest: "generatedNotice" must be a boolean.')
validateTransform(value.transform)
if (!Array.isArray(value.icons)) throw new Error('Remote sprite manifest: "icons" must be an array.')
value.icons.forEach(validateIcon)
if (value.iconCount !== value.icons.length) throw new Error('Remote sprite manifest: iconCount does not match icons.')
if (new Set(value.icons.map((icon) => icon.name)).size !== value.icons.length) {
throw new Error('Remote sprite manifest contains duplicate icon names.')
}
if (new Set(value.icons.map((icon) => icon.id)).size !== value.icons.length) {
throw new Error('Remote sprite manifest contains duplicate icon IDs.')
}
if (!isRecord(value.sprites)) throw new Error('Remote sprite manifest: "sprites" must be an object.')
validateAsset(value.sprites.stack, 'stack')
validateAsset(value.sprites['stack-root-viewbox'], 'stack-root-viewbox')
}
function isHttpLocation(location: string): boolean {
return /^https?:\/\//i.test(location)
}
async function fetchBytes(url: string, maximumBytes: number, label: string): Promise<{ bytes: Uint8Array; location: string }> {
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
if (!response.ok) throw new Error(`Cannot load ${label}: ${response.status} ${response.statusText}`)
const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
throw new Error(`${label} exceeds the ${maximumBytes} byte limit.`)
}
const bytes = new Uint8Array(await response.arrayBuffer())
if (bytes.byteLength > maximumBytes) throw new Error(`${label} exceeds the ${maximumBytes} byte limit.`)
return { bytes, location: response.url }
}
async function readResource(location: string, maximumBytes: number, label: string): Promise<{ bytes: Uint8Array; location: string }> {
if (isHttpLocation(location)) return fetchBytes(location, maximumBytes, label)
const bytes = fs.readFileSync(location)
if (bytes.byteLength > maximumBytes) throw new Error(`${label} exceeds the ${maximumBytes} byte limit.`)
return { bytes, location: path.resolve(location) }
}
function resolveAssetLocation(manifestLocation: string, href: string): string {
if (isHttpLocation(href)) return href
if (isHttpLocation(manifestLocation)) return new URL(href, manifestLocation).href
return path.resolve(path.dirname(manifestLocation), href)
}
export async function prepareRemoteSprite(manifestLocation: string): Promise<PreparedRemoteSprite> {
const resource = await readResource(manifestLocation, MAX_MANIFEST_BYTES, 'remote sprite manifest')
let manifest: unknown
try {
manifest = JSON.parse(new TextDecoder().decode(resource.bytes))
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`Cannot parse remote sprite manifest: ${reason}`)
}
validateServerSpriteManifest(manifest)
return {
kind: 'remote',
manifest,
manifestLocation: resource.location,
iconNames: manifest.icons.map((icon) => icon.name),
}
}
function sameIcons(actual: readonly CompiledIcon[], expected: readonly CompiledIcon[]): boolean {
return JSON.stringify(actual) === JSON.stringify(expected)
}
export async function loadRemoteSpriteArtifact(
prepared: PreparedRemoteSprite,
profile: SpriteCompileProfile,
): Promise<CompiledSpriteArtifact> {
const asset = prepared.manifest.sprites[profile]
const location = resolveAssetLocation(prepared.manifestLocation, asset.href)
const resource = await readResource(location, MAX_SPRITE_BYTES, `remote sprite profile "${profile}"`)
if (resource.bytes.byteLength !== asset.byteLength) {
throw new Error(`Remote sprite profile "${profile}" byteLength does not match its manifest.`)
}
if (sha256(resource.bytes) !== asset.sha256) {
throw new Error(`Remote sprite profile "${profile}" SHA-256 does not match its manifest.`)
}
const artifact = createCompiledSpriteArtifact(resource.bytes, prepared.iconNames, 'stack')
if (!sameIcons(artifact.icons, prepared.manifest.icons)) {
throw new Error(`Remote sprite profile "${profile}" icon metadata does not match its manifest.`)
}
return artifact
}

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

@@ -0,0 +1,26 @@
import type { CompiledIcon } from '../core/compiled-artifact.js'
import type { TransformOptions } from '../types.js'
export type SpriteCompileProfile = 'stack' | 'stack-root-viewbox'
export type ServerSpriteAsset = {
readonly href: string
readonly sha256: string
readonly byteLength: number
}
export type ServerSpriteManifest = {
readonly kind: '@gromlab/svg-sprites/server'
readonly schemaVersion: 1
readonly generator: '@gromlab/svg-sprites'
readonly mode: 'standalone@server'
readonly target: 'server'
readonly name: string
readonly description?: string
readonly format: 'stack'
readonly generatedNotice: boolean
readonly transform: Required<TransformOptions>
readonly iconCount: number
readonly icons: readonly CompiledIcon[]
readonly sprites: Readonly<Record<SpriteCompileProfile, ServerSpriteAsset>>
}

View File

@@ -14,6 +14,9 @@ export type ViteAssetTarget = 'vite'
*/
export type WebpackAssetTarget = 'webpack'
/** Target серверной сборки immutable sprite release. */
export type ServerAssetTarget = 'server'
/** Asset target для проекта без сборщика. */
export type StaticAssetTarget = 'static'
@@ -60,8 +63,11 @@ export type StandaloneAssetTarget = StaticAssetTarget | ViteAssetTarget | Webpac
/** Полный ключ standalone mode, используемый конфигом, CLI и manifest. */
export type StandaloneSpriteMode = 'standalone' | `standalone@${ViteAssetTarget | WebpackAssetTarget}`
/** Серверный mode, публикующий готовые sprite profiles и manifest. */
export type ServerSpriteMode = 'standalone@server'
/** Любая среда, для которой может быть сгенерирован React sprite-модуль. */
export type SpriteAssetTarget = ReactAssetTarget | NextAssetTarget | StaticAssetTarget | AngularAssetTarget
export type SpriteAssetTarget = ReactAssetTarget | NextAssetTarget | StaticAssetTarget | AngularAssetTarget | ServerAssetTarget
/** Режим генерации sprite-модуля. */
export type SpriteMode =
@@ -78,6 +84,7 @@ export type SpriteMode =
| AlpineSpriteMode
| NextAssetTarget
| StandaloneSpriteMode
| ServerSpriteMode
/** Фрагменты кода, необходимые компоненту для получения URL SVG asset. */
export type SpriteAssetUrlCode = {

View File

@@ -1,5 +1,16 @@
import type { SpriteMode } from './targets/types.js'
export type SpriteSource = 'local' | 'remote'
/** HTTP SVG input серверного mode. Имя задаёт публичное имя иконки. */
export type ServerSvgInput = {
name: string
url: string
sha256?: string
}
export type SpriteInput = string | ServerSvgInput
/** Формат спрайта: stack или symbol. */
export type SpriteFormat = 'stack' | 'symbol'
@@ -24,16 +35,18 @@ export type TransformOptions = {
addTransition?: boolean
}
/** Единая конфигурация локального sprite-модуля. */
/** Единая конфигурация sprite-модуля. Exact mode уточняет допустимый input. */
export type SpriteConfig = {
/** Режим можно определить в конфиге либо передать через CLI/API. */
mode?: SpriteMode
/** Локальные SVG по умолчанию либо готовый server manifest. */
source?: SpriteSource
/** Логическое имя спрайта. По умолчанию выводится из каталога модуля. */
name?: string
/** Описание спрайта для generated types и manifest. */
description?: string
/** Путь или glob-шаблоны исходных SVG относительно корня модуля. По умолчанию: ./icons. */
input?: string | string[]
input?: SpriteInput | SpriteInput[]
/** Настройки трансформации SVG. По умолчанию все включены. */
transform?: TransformOptions
/** Добавлять развёрнутое предупреждение в generated-файлы. По умолчанию: true. */
@@ -43,9 +56,10 @@ export type SpriteConfig = {
/** Полностью разрешённая конфигурация, готовая к генерации. */
export type ResolvedSpriteConfig = {
mode: SpriteMode
source: SpriteSource
name: string
description?: string
input: string[]
input: SpriteInput[]
transform: Required<TransformOptions>
generatedNotice: boolean
}