diff --git a/.changeset/cyan-tables-poke.md b/.changeset/cyan-tables-poke.md new file mode 100644 index 000000000000..045fd24847b5 --- /dev/null +++ b/.changeset/cyan-tables-poke.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Add experimental SVGO optimization support for SVG assets diff --git a/packages/astro/package.json b/packages/astro/package.json index 290a6cbf086d..afca7c08bb15 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", diff --git a/packages/astro/src/assets/utils/svg.ts b/packages/astro/src/assets/utils/svg.ts index c272f345c18c..d444df4013af 100644 --- a/packages/astro/src/assets/utils/svg.ts +++ b/packages/astro/src/assets/utils/svg.ts @@ -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', ); @@ -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), diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 34c3d123ef65..187eae2fd816 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -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 { @@ -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. diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 3df0c5f367de..b917bbdc3415 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -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'; @@ -105,6 +106,10 @@ export const ASTRO_CONFIG_DEFAULTS = { staticImportMetaEnv: false, chromeDevtoolsWorkspace: false, failOnPrerenderConflict: false, + svg: { + optimize: true, + svgoConfig: {}, + }, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -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((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.`, diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 20b6f74c15ce..d5f7f60539d4 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -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'; @@ -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; + }; }; } diff --git a/packages/astro/test/core-image-svg.test.js b/packages/astro/test/core-image-svg.test.js index 308554be1069..bbaede3a6ad3 100644 --- a/packages/astro/test/core-image-svg.test.js +++ b/packages/astro/test/core-image-svg.test.js @@ -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'); + }); + }); + }); }); diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/astro.config.mjs b/packages/astro/test/fixtures/core-image-svg-optimized/astro.config.mjs new file mode 100644 index 000000000000..84d8a09b8e5f --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg-optimized/astro.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + experimental: { + svg: { + optimize: true, + svgoConfig: { + plugins: [ + 'preset-default', + { + name: 'removeViewBox', + active: false + } + ] + } + } + } +}); diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/package.json b/packages/astro/test/fixtures/core-image-svg-optimized/package.json new file mode 100644 index 000000000000..4557b402af29 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg-optimized/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/core-image-svg-optimized", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + }, + "scripts": { + "dev": "astro dev" + } +} diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/src/assets/unoptimized.svg b/packages/astro/test/fixtures/core-image-svg-optimized/src/assets/unoptimized.svg new file mode 100644 index 000000000000..49293779d377 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg-optimized/src/assets/unoptimized.svg @@ -0,0 +1,16 @@ + + + + + Test Icon + An icon for testing SVGO optimization + + + + + + + + diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/src/pages/optimized.astro b/packages/astro/test/fixtures/core-image-svg-optimized/src/pages/optimized.astro new file mode 100644 index 000000000000..69ce261c9bd0 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg-optimized/src/pages/optimized.astro @@ -0,0 +1,14 @@ +--- +import TestIcon from '../assets/unoptimized.svg'; +--- + + + + SVG Optimization Test + + +
+ +
+ + diff --git a/packages/astro/test/fixtures/core-image-svg-optimized/tsconfig.json b/packages/astro/test/fixtures/core-image-svg-optimized/tsconfig.json new file mode 100644 index 000000000000..923ed4e24fb7 --- /dev/null +++ b/packages/astro/test/fixtures/core-image-svg-optimized/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/assets/*": [ + "src/assets/*" + ] + }, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f89e015c462b..41b2a7b4c7ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -609,6 +609,9 @@ importers: smol-toml: specifier: ^1.4.2 version: 1.4.2 + svgo: + specifier: ^4.0.0 + version: 4.0.0 tinyexec: specifier: ^1.0.1 version: 1.0.1 @@ -2864,6 +2867,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/core-image-svg-optimized: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/core-image-unconventional-settings: dependencies: astro: