diff --git a/plugin/README.md b/plugin/README.md index 7375ed0..2c25772 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -126,10 +126,10 @@ svgJar({ // Default: 'dom' target: 'dom' | 'ember' | 'react' | 'preact' | 'vue' | 'solid' | 'web-component', - // SVGO configuration. - // true (default): use baseline config with sensible defaults + // SVGO configuration. See "SVGO optimization" below. + // true (default): use the baseline config // false: disable optimization entirely - // object: custom SVGO config, deep-merged with baseline + // object: custom SVGO config, used in place of the baseline svgo: true, // Default sprite name for bare SVG imports. @@ -417,6 +417,21 @@ SVGO runs per-SVG during the `load` phase (not on assembled sprites) to prevent Runs in both dev and prod so behaviour is consistent. +A custom `svgo` config replaces the baseline config. svg-jar does not merge them. To keep the baseline and add plugins, copy the baseline plugins from `baselineSvgoConfig`: + +```ts +import { baselineSvgoConfig } from '@svg-jar/plugin'; +import svgJar from '@svg-jar/plugin/vite'; + +svgJar({ + svgo: { + plugins: [...baselineSvgoConfig.plugins, 'removeDimensions'], + }, +}); +``` + +If you write a config without the baseline, keep the `cleanupIds` override from it: `{ name: 'preset-default', params: { overrides: { cleanupIds: { minify: false, remove: false } } } }`. Without it, SVGO shortens IDs in each file to names like `a` and `b`. Icons from different files then use the same IDs in one sprite sheet, and a `url(#a)` reference can point to the gradient or clip path of a different icon. + ### Embedded reference resolution SVGs containing `` or `` have their references resolved through the bundler's module graph: diff --git a/plugin/src/core/options.ts b/plugin/src/core/options.ts index 43336e9..0752275 100644 --- a/plugin/src/core/options.ts +++ b/plugin/src/core/options.ts @@ -13,9 +13,10 @@ export interface SvgJarOptions { /** * SVGO configuration. - * - `true` (default): use baseline config with sensible defaults + * - `true` (default): use `baselineSvgoConfig` * - `false`: disable optimization entirely - * - object: custom SVGO config, deep-merged with baseline + * - object: custom SVGO config, used in place of the baseline. To extend + * the baseline, copy `baselineSvgoConfig.plugins` into it. */ svgo?: boolean | SvgoConfig; diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 65fa940..5bf66ce 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -77,3 +77,4 @@ export const SvgJarPlugin: UnpluginInstance = }); export type { SvgJarOptions, SvgJarTarget } from './core/options.ts'; +export { baselineSvgoConfig } from './svg/optimize.ts'; diff --git a/plugin/src/svg/optimize.ts b/plugin/src/svg/optimize.ts index 4ebf9fc..abe31e7 100644 --- a/plugin/src/svg/optimize.ts +++ b/plugin/src/svg/optimize.ts @@ -1,15 +1,30 @@ -import { optimize, type Config as SvgoConfig } from 'svgo'; +import { optimize, type Config as SvgoConfig, type PluginConfig } from 'svgo'; export type { Config as SvgoConfig } from 'svgo'; +/** Freezes an object and every object inside it. */ +function deepFreeze(value: T): T { + for (const child of Object.values(value)) { + if (typeof child === 'object' && child !== null) deepFreeze(child); + } + return Object.freeze(value); +} + /** * Baseline SVGO configuration used when `svgo: true` (the default). * * Preserves IDs (needed for sprite symbol references), path data * precision, numeric values, and hidden elements. Strips `` * elements since they cause tooltip issues in browsers. + * + * A custom `svgo` config replaces this one. To extend it, copy its plugins + * into the custom config. It is frozen so that changes made by one caller + * cannot change the default for every build. + * + * @example + * svgJar({ svgo: { plugins: [...baselineSvgoConfig.plugins, 'removeDimensions'] } }) */ -const BASELINE_CONFIG: SvgoConfig = { +export const baselineSvgoConfig: { readonly plugins: readonly PluginConfig[] } = deepFreeze({ plugins: [ { name: 'preset-default', @@ -24,14 +39,14 @@ const BASELINE_CONFIG: SvgoConfig = { }, 'removeTitle', ], -}; +}); /** * Optimizes an SVG string using SVGO. * * @param svg The raw SVG string to optimize. * @param config Controls optimization behavior: - * - `true`: use baseline config (default) + * - `true`: use `baselineSvgoConfig` (default) * - `false`: return the SVG unchanged * - object: custom SVGO config (used as-is, not merged) * @returns The optimized SVG string. @@ -46,7 +61,7 @@ export function optimizeSvg(svg: string, config: boolean | SvgoConfig): string { return svg; } - const svgoConfig = config === true ? BASELINE_CONFIG : config; + const svgoConfig = config === true ? { plugins: [...baselineSvgoConfig.plugins] } : config; const result = optimize(svg, svgoConfig); return result.data; } diff --git a/plugin/test/unit/optimize.test.ts b/plugin/test/unit/optimize.test.ts index 53e23e7..c8701d6 100644 --- a/plugin/test/unit/optimize.test.ts +++ b/plugin/test/unit/optimize.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { describe, it, expect } from 'vitest'; import { optimizeSvg } from '../../src/svg/optimize.ts'; +import { baselineSvgoConfig } from '../../src/index.ts'; const FIXTURES_DIR = path.join(import.meta.dirname, '..', '_fixtures'); @@ -71,6 +72,34 @@ describe('optimizeSvg', () => { }); }); + describe('baselineSvgoConfig', () => { + it('is the config used when svgo is true', () => { + const input = readFixture('source-order.svg'); + + expect(optimizeSvg(input, { plugins: [...baselineSvgoConfig.plugins] })).toBe(optimizeSvg(input, true)); + }); + + it('can be extended with more plugins', () => { + const input = readFixture('with-dimensions.svg'); + const output = optimizeSvg(input, { plugins: [...baselineSvgoConfig.plugins, 'removeDimensions'] }); + + // removeDimensions ran + expect(output).not.toContain('width="24"'); + // The baseline cleanupIds settings still apply + expect(optimizeSvg(readFixture('nested-groups.svg'), { plugins: [...baselineSvgoConfig.plugins] })).toContain( + 'id="outer"', + ); + }); + + it('cannot be changed by the caller', () => { + expect(Object.isFrozen(baselineSvgoConfig)).toBe(true); + expect(Object.isFrozen(baselineSvgoConfig.plugins)).toBe(true); + expect(() => { + (baselineSvgoConfig.plugins as unknown[]).push('removeDimensions'); + }).toThrow(TypeError); + }); + }); + describe('with custom config', () => { it('applies the custom SVGO config', () => { const input = @@ -82,5 +111,14 @@ describe('optimizeSvg', () => { expect(output).not.toContain('<!-- comment -->'); expect(output).toContain('<path'); }); + + it('uses the custom config in place of the baseline, without merging', () => { + const input = + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>My Icon'; + const output = optimizeSvg(input, { plugins: ['removeComments'] }); + + // The baseline removes , so its presence shows the baseline did not run + expect(output).toContain('<title>My Icon'); + }); }); });