Skip to content

Commit 1a2ed01

Browse files
azat-iosarah11918florian-lefebvre
authored
feat: add SVGO optimization support for SVG assets (#13880)
Co-authored-by: Sarah Rainsberger <[email protected]> Co-authored-by: Florian Lefebvre <[email protected]>
1 parent 3030556 commit 1a2ed01

File tree

14 files changed

+265
-5
lines changed

14 files changed

+265
-5
lines changed

.changeset/cyan-tables-poke.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Adds experimental SVGO optimization support for SVG assets
6+
7+
Astro now supports automatic SVG optimization using SVGO during build time. This experimental feature helps reduce SVG file sizes while maintaining visual quality, improving your site's performance.
8+
9+
To enable SVG optimization with default settings, add the following to your `astro.config.mjs`:
10+
```js
11+
import { defineConfig } from 'astro/config';
12+
13+
export default defineConfig({
14+
experimental: {
15+
svgo: true,
16+
},
17+
});
18+
```
19+
20+
To customize optimization, pass a [SVGO configuration object](https://svgo.dev/docs/plugins/):
21+
22+
```js
23+
export default defineConfig({
24+
experimental: {
25+
svgo: {
26+
plugins: [
27+
'preset-default',
28+
{
29+
name: 'removeViewBox',
30+
active: false
31+
}
32+
],
33+
},
34+
},
35+
});
36+
```
37+
38+
For more information on enabling and using this feature in your project, see the [experimental SVG optimization docs](https://docs.astro.build/en/reference/experimental-flags/svg-optimization/).

packages/astro/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@
156156
"semver": "^7.7.3",
157157
"shiki": "^3.15.0",
158158
"smol-toml": "^1.5.0",
159+
"svgo": "^4.0.0",
159160
"tinyexec": "^1.0.2",
160161
"tinyglobby": "^0.2.15",
161162
"tsconfck": "^3.1.6",

packages/astro/src/assets/utils/svg.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,37 @@
1+
import { optimize } from 'svgo';
12
import { parse, renderSync } from 'ultrahtml';
3+
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
4+
import type { AstroConfig } from '../../types/public/config.js';
25
import type { SvgComponentProps } from '../runtime.js';
36
import { dropAttributes } from '../runtime.js';
47
import type { ImageMetadata } from '../types.js';
58

6-
function parseSvg(contents: string) {
7-
const root = parse(contents);
9+
function parseSvg({
10+
path,
11+
contents,
12+
svgoConfig,
13+
}: {
14+
path: string;
15+
contents: string;
16+
svgoConfig: AstroConfig['experimental']['svgo'];
17+
}) {
18+
let processedContents = contents;
19+
if (svgoConfig) {
20+
try {
21+
const config = typeof svgoConfig === 'boolean' ? undefined : svgoConfig;
22+
const result = optimize(contents, config);
23+
processedContents = result.data;
24+
} catch (cause) {
25+
throw new AstroError(
26+
{
27+
...AstroErrorData.CannotOptimizeSvg,
28+
message: AstroErrorData.CannotOptimizeSvg.message(path),
29+
},
30+
{ cause },
31+
);
32+
}
33+
}
34+
const root = parse(processedContents);
835
const svgNode = root.children.find(
936
({ name, type }: { name: string; type: number }) => type === 1 /* Element */ && name === 'svg',
1037
);
@@ -17,9 +44,17 @@ function parseSvg(contents: string) {
1744
return { attributes, body };
1845
}
1946

20-
export function makeSvgComponent(meta: ImageMetadata, contents: Buffer | string) {
47+
export function makeSvgComponent(
48+
meta: ImageMetadata,
49+
contents: Buffer | string,
50+
svgoConfig: AstroConfig['experimental']['svgo'],
51+
): string {
2152
const file = typeof contents === 'string' ? contents : contents.toString('utf-8');
22-
const { attributes, body: children } = parseSvg(file);
53+
const { attributes, body: children } = parseSvg({
54+
path: meta.fsPath,
55+
contents: file,
56+
svgoConfig,
57+
});
2358
const props: SvgComponentProps = {
2459
meta,
2560
attributes: dropAttributes(attributes),

packages/astro/src/assets/vite-plugin-assets.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
272272
encoding: 'utf8',
273273
});
274274
// We know that the contents are present, as we only emit this property for SVG files
275-
return { code: makeSvgComponent(imageMetadata, contents) };
275+
return {
276+
code: makeSvgComponent(imageMetadata, contents, settings.config.experimental.svgo),
277+
};
276278
}
277279
return {
278280
code: `export default ${getProxyCode(

packages/astro/src/core/config/schemas/base.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
} from '@astrojs/markdown-remark';
88
import { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-remark';
99
import { type BuiltinTheme, bundledThemes } from 'shiki';
10+
import type { Config as SvgoConfig } from 'svgo';
1011
import { z } from 'zod';
1112
import { localFontFamilySchema, remoteFontFamilySchema } from '../../../assets/fonts/config.js';
1213
import { EnvSchema } from '../../../env/schema.js';
@@ -106,6 +107,7 @@ export const ASTRO_CONFIG_DEFAULTS = {
106107
staticImportMetaEnv: false,
107108
chromeDevtoolsWorkspace: false,
108109
failOnPrerenderConflict: false,
110+
svgo: false,
109111
},
110112
} satisfies AstroUserConfig & { server: { open: boolean } };
111113

@@ -526,6 +528,10 @@ export const AstroConfigSchema = z.object({
526528
.boolean()
527529
.optional()
528530
.default(ASTRO_CONFIG_DEFAULTS.experimental.failOnPrerenderConflict),
531+
svgo: z
532+
.union([z.boolean(), z.custom<SvgoConfig>((value) => value && typeof value === 'object')])
533+
.optional()
534+
.default(ASTRO_CONFIG_DEFAULTS.experimental.svgo),
529535
})
530536
.strict(
531537
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`,

packages/astro/src/core/errors/errors-data.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2064,6 +2064,17 @@ export const SessionConfigWithoutFlagError = {
20642064
hint: 'For more information, see https://docs.astro.build/en/guides/sessions/',
20652065
} satisfies ErrorData;
20662066

2067+
/**
2068+
* @docs
2069+
* @message An error occurred while optimizing the SVG file with SVGO.
2070+
*/
2071+
export const CannotOptimizeSvg = {
2072+
name: 'CannotOptimizeSvg',
2073+
title: 'Cannot optimize SVG',
2074+
message: (path: string) => `An error occurred while optimizing SVG file "${path}" with SVGO.`,
2075+
hint: 'Review the included SVGO error message provided for guidance.',
2076+
} satisfies ErrorData;
2077+
20672078
/*
20682079
* Adding an error? Follow these steps:
20692080
* 1. Determine in which category it belongs (Astro, Vite, CSS, Content Collections etc.)

packages/astro/src/types/public/config.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ShikiConfig,
88
SyntaxHighlightConfigType,
99
} from '@astrojs/markdown-remark';
10+
import type { Config as SvgoConfig } from 'svgo';
1011
import type { BuiltinDriverName, BuiltinDriverOptions, Driver, Storage } from 'unstorage';
1112
import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
1213
import type { AstroFontProvider, FontFamily } from '../../assets/fonts/types.js';
@@ -2537,6 +2538,50 @@ export interface AstroUserConfig<
25372538
* See the [experimental Chrome DevTools workspace feature documentation](https://docs.astro.build/en/reference/experimental-flags/chrome-devtools-workspace/) for more information.
25382539
*/
25392540
chromeDevtoolsWorkspace?: boolean;
2541+
2542+
/**
2543+
* @name experimental.svgo
2544+
* @type {boolean | SvgoConfig}
2545+
* @default `false`
2546+
* @description
2547+
* Enable SVG optimization using SVGO during build time.
2548+
*
2549+
* Set to `true` to enable optimization with default settings, or pass a configuration
2550+
* object to customize SVGO behavior.
2551+
*
2552+
* When enabled, all imported SVG files will be optimized for smaller file sizes
2553+
* and better performance while maintaining visual quality.
2554+
*
2555+
* ```js
2556+
* {
2557+
* experimental: {
2558+
* // Enable with defaults
2559+
* svgo: true
2560+
* }
2561+
* }
2562+
* ```
2563+
*
2564+
* To customize optimization, pass a [SVGO configuration object](https://svgo.dev/):
2565+
*
2566+
* ```js
2567+
* {
2568+
* experimental: {
2569+
* svgo: {
2570+
* plugins: [
2571+
* 'preset-default',
2572+
* {
2573+
* name: 'removeViewBox',
2574+
* active: false
2575+
* }
2576+
* ]
2577+
* }
2578+
* }
2579+
* }
2580+
* ```
2581+
*
2582+
* See the [experimental SVGO optimization docs](https://docs.astro.build/en/reference/experimental-flags/svg-optimization/) for more information.
2583+
*/
2584+
svgo?: boolean | SvgoConfig;
25402585
};
25412586
}
25422587

packages/astro/test/core-image-svg.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,50 @@ describe('astro:assets - SVG Components', () => {
150150
});
151151
});
152152
});
153+
154+
describe('SVGO optimization', () => {
155+
/** @type {import('./test-utils').Fixture} */
156+
let optimizedFixture;
157+
/** @type {import('./test-utils').DevServer} */
158+
let optimizedDevServer;
159+
160+
before(async () => {
161+
optimizedFixture = await loadFixture({
162+
root: './fixtures/core-image-svg-optimized/',
163+
});
164+
165+
optimizedDevServer = await optimizedFixture.startDevServer();
166+
});
167+
168+
after(async () => {
169+
await optimizedDevServer.stop();
170+
});
171+
172+
describe('with optimization enabled', () => {
173+
let $;
174+
let html;
175+
176+
before(async () => {
177+
let res = await optimizedFixture.fetch('/optimized');
178+
html = await res.text();
179+
$ = cheerio.load(html, { xml: true });
180+
});
181+
182+
it('optimizes SVG with SVGO', () => {
183+
const $svg = $('#optimized svg');
184+
assert.equal($svg.length, 1);
185+
assert.equal(html.includes('This is a comment'), false);
186+
assert.equal(!!$svg.attr('xmlns:xlink'), false);
187+
assert.equal(!!$svg.attr('version'), false);
188+
});
189+
190+
it('preserves functional SVG structure', () => {
191+
const $svg = $('#optimized svg');
192+
const $paths = $svg.find('path');
193+
assert.equal($paths.length >= 1, true);
194+
assert.equal($svg.attr('width'), '24');
195+
assert.equal($svg.attr('height'), '24');
196+
});
197+
});
198+
});
153199
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { defineConfig } from 'astro/config';
2+
3+
export default defineConfig({
4+
experimental: {
5+
svgo: {
6+
plugins: [
7+
'preset-default',
8+
{
9+
name: 'removeViewBox',
10+
active: false,
11+
},
12+
],
13+
},
14+
},
15+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@test/core-image-svg-optimized",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"astro": "workspace:*"
7+
},
8+
"scripts": {
9+
"dev": "astro dev"
10+
}
11+
}

0 commit comments

Comments
 (0)