Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<use href="other.svg">` or `<image href="photo.png">` have their references resolved through the bundler's module graph:
Expand Down
5 changes: 3 additions & 2 deletions plugin/src/core/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ export const SvgJarPlugin: UnpluginInstance<SvgJarOptions | undefined, false> =
});

export type { SvgJarOptions, SvgJarTarget } from './core/options.ts';
export { baselineSvgoConfig } from './svg/optimize.ts';
25 changes: 20 additions & 5 deletions plugin/src/svg/optimize.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(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 `<title>`
* 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',
Expand All @@ -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.
Expand All @@ -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;
}
38 changes: 38 additions & 0 deletions plugin/test/unit/optimize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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 =
Expand All @@ -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</title><path d="M0 0"/></svg>';
const output = optimizeSvg(input, { plugins: ['removeComments'] });

// The baseline removes <title>, so its presence shows the baseline did not run
expect(output).toContain('<title>My Icon</title>');
});
});
});
Loading