Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/cyan-tables-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Add experimental SVGO optimization support for SVG assets
1 change: 1 addition & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
"semver": "^7.7.2",
"shiki": "^3.12.0",
"smol-toml": "^1.4.2",
"svgo": "^4.0.0",
"tinyexec": "^1.0.1",
"tinyglobby": "^0.2.14",
"tsconfck": "^3.1.6",
Expand Down
26 changes: 22 additions & 4 deletions packages/astro/src/assets/utils/svg.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import { optimize } from 'svgo';

import { parse, renderSync } from 'ultrahtml';
import type { AstroConfigType } from '../../core/config/schemas/index.js';

import type { SvgComponentProps } from '../runtime.js';
import { dropAttributes } from '../runtime.js';
import type { ImageMetadata } from '../types.js';

function parseSvg(contents: string) {
const root = parse(contents);
function parseSvg(contents: string, svgConfig?: AstroConfigType['experimental']['svg']) {
let processedContents = contents;
if (svgConfig?.optimize) {
try {
const result = optimize(contents, svgConfig.svgoConfig);
processedContents = result.data;
} catch (error) {
console.warn('SVGO optimization failed:', error);
processedContents = contents;
}
}
const root = parse(processedContents);
const svgNode = root.children.find(
({ name, type }: { name: string; type: number }) => type === 1 /* Element */ && name === 'svg',
);
Expand All @@ -17,9 +31,13 @@ function parseSvg(contents: string) {
return { attributes, body };
}

export function makeSvgComponent(meta: ImageMetadata, contents: Buffer | string) {
export function makeSvgComponent(
meta: ImageMetadata,
contents: Buffer | string,
svgConfig?: AstroConfigType['experimental']['svg'],
): string {
const file = typeof contents === 'string' ? contents : contents.toString('utf-8');
const { attributes, body: children } = parseSvg(file);
const { attributes, body: children } = parseSvg(file, svgConfig);
const props: SvgComponentProps = {
meta,
attributes: dropAttributes(attributes),
Expand Down
9 changes: 8 additions & 1 deletion packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type * as fsMod from 'node:fs';
import { extname } from 'node:path';
import MagicString from 'magic-string';
import type * as vite from 'vite';
import type { AstroConfigType } from '../core/config/schemas/index.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import type { Logger } from '../core/logger/core.js';
import {
Expand Down Expand Up @@ -244,7 +245,13 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
if (id.endsWith('.svg')) {
const contents = await fs.promises.readFile(imageMetadata.fsPath, { encoding: 'utf8' });
// We know that the contents are present, as we only emit this property for SVG files
return { code: makeSvgComponent(imageMetadata, contents) };
return {
code: makeSvgComponent(
imageMetadata,
contents,
settings.config.experimental?.svg as AstroConfigType['experimental']['svg'],
),
};
}

// We can only reliably determine if an image is used on the server, as we need to track its usage throughout the entire build.
Expand Down
13 changes: 13 additions & 0 deletions packages/astro/src/core/config/schemas/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from '@astrojs/markdown-remark';
import { markdownConfigDefaults, syntaxHighlightDefaults } from '@astrojs/markdown-remark';
import { type BuiltinTheme, bundledThemes } from 'shiki';
import type { Config as SvgoConfig } from 'svgo';
import { z } from 'zod';
import { localFontFamilySchema, remoteFontFamilySchema } from '../../../assets/fonts/config.js';
import { EnvSchema } from '../../../env/schema.js';
Expand Down Expand Up @@ -105,6 +106,10 @@ export const ASTRO_CONFIG_DEFAULTS = {
staticImportMetaEnv: false,
chromeDevtoolsWorkspace: false,
failOnPrerenderConflict: false,
svg: {
optimize: true,
svgoConfig: {},
},
},
} satisfies AstroUserConfig & { server: { open: boolean } };

Expand Down Expand Up @@ -515,6 +520,14 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.failOnPrerenderConflict),
svg: z
.object({
optimize: z.boolean().default(true),
svgoConfig: z
.custom<SvgoConfig>((value) => value && typeof value === 'object')
.optional(),
})
.optional(),
})
.strict(
`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.`,
Expand Down
49 changes: 49 additions & 0 deletions packages/astro/src/types/public/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ShikiConfig,
SyntaxHighlightConfigType,
} from '@astrojs/markdown-remark';
import type { Config as SvgoConfig } from 'svgo';
import type { BuiltinDriverName, BuiltinDriverOptions, Driver, Storage } from 'unstorage';
import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
import type { AstroFontProvider, FontFamily } from '../../assets/fonts/types.js';
Expand Down Expand Up @@ -2495,6 +2496,54 @@ export interface AstroUserConfig<
* See the [experimental Chrome DevTools workspace feature documentation](https://docs.astro.build/en/reference/experimental-flags/chrome-devtools-workspace/) for more information.
*/
chromeDevtoolsWorkspace?: boolean;

/**
* @docs
* @kind heading
* @name SVG Options
*/
svg?: {
/**
* @docs
* @name experimental.svg.optimize
* @type {boolean}
* @default `true`
* @description
* Whether to enable SVG optimization using SVGO during build time.
*
* When enabled, all imported SVG files will be optimized for smaller file sizes
* and better performance while maintaining visual quality.
*/
optimize?: boolean;

/**
* @docs
* @name experimental.svg.svgoConfig
* @type {SvgoConfig}
* @default `{}`
* @description
* Configuration object passed directly to SVGO for customizing SVG optimization.
*
* See [SVGO documentation](https://svgo.dev/) for available options.
*
* ```js
* {
* svg: {
* svgoConfig: {
* plugins: [
* 'preset-default',
* {
* name: 'removeViewBox',
* active: false
* }
* ]
* }
* }
* }
* ```
*/
svgoConfig?: SvgoConfig;
};
};
}

Expand Down
46 changes: 46 additions & 0 deletions packages/astro/test/core-image-svg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,50 @@ describe('astro:assets - SVG Components', () => {
});
});
});

describe('SVGO optimization', () => {
/** @type {import('./test-utils').Fixture} */
let optimizedFixture;
/** @type {import('./test-utils').DevServer} */
let optimizedDevServer;

before(async () => {
optimizedFixture = await loadFixture({
root: './fixtures/core-image-svg-optimized/',
});

optimizedDevServer = await optimizedFixture.startDevServer();
});

after(async () => {
await optimizedDevServer.stop();
});

describe('with optimization enabled', () => {
let $;
let html;

before(async () => {
let res = await optimizedFixture.fetch('/optimized');
html = await res.text();
$ = cheerio.load(html, { xml: true });
});

it('optimizes SVG with SVGO', () => {
const $svg = $('#optimized svg');
assert.equal($svg.length, 1);
assert.equal(html.includes('This is a comment'), false);
assert.equal(!!$svg.attr('xmlns:xlink'), false);
assert.equal(!!$svg.attr('version'), false);
});

it('preserves functional SVG structure', () => {
const $svg = $('#optimized svg');
const $paths = $svg.find('path');
assert.equal($paths.length >= 1, true);
assert.equal($svg.attr('width'), '24');
assert.equal($svg.attr('height'), '24');
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineConfig } from 'astro/config';

export default defineConfig({
experimental: {
svg: {
optimize: true,
svgoConfig: {
plugins: [
'preset-default',
{
name: 'removeViewBox',
active: false
}
]
}
}
}
});
11 changes: 11 additions & 0 deletions packages/astro/test/fixtures/core-image-svg-optimized/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@test/core-image-svg-optimized",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
},
"scripts": {
"dev": "astro dev"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
import TestIcon from '../assets/unoptimized.svg';
---

<html>
<head>
<title>SVG Optimization Test</title>
</head>
<body>
<div id="optimized">
<TestIcon />
</div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/assets/*": [
"src/assets/*"
]
},
}
}
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading