diff --git a/.changeset/twelve-times-slide.md b/.changeset/twelve-times-slide.md new file mode 100644 index 000000000000..993897747256 --- /dev/null +++ b/.changeset/twelve-times-slide.md @@ -0,0 +1,27 @@ +--- +'astro': minor +--- + +Adds an experimental flag `svgOptimizer` that enables automatic optimization of your SVG components using the provided optimizer. This supersedes the `svgo` experimental flag, which is now removed. + +When enabled, your imported SVG files used as components will be optimized for smaller file sizes and better performance while maintaining visual quality. This can significantly reduce the size of your SVG assets by removing unnecessary metadata, comments, and redundant code. + +Astro ships with a [SVGO](https://svgo.dev/) based optimizer, but any can be used. + +To enable this feature, add the experimental flag in your Astro config and remove `svgo` if it was enabled: + + +```diff +// astro.config.mjs +-import { defineConfig } from "astro/config"; ++import { defineConfig, svgoOptimizer } from "astro/config"; + +export default defineConfig({ ++ experimental: { ++ svgOptimizer: svgoOptimizer() +- svgo: true ++ } +}); +``` + +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/). diff --git a/packages/astro/src/assets/svg/config.ts b/packages/astro/src/assets/svg/config.ts new file mode 100644 index 000000000000..ce48b6177874 --- /dev/null +++ b/packages/astro/src/assets/svg/config.ts @@ -0,0 +1,7 @@ +import * as z from 'zod/v4'; +import type { SvgOptimizer } from './types.js'; + +export const SvgOptimizerSchema = z.object({ + name: z.string(), + optimize: z.custom((v) => typeof v === 'function'), +}); diff --git a/packages/astro/src/assets/svg/svgo.ts b/packages/astro/src/assets/svg/svgo.ts new file mode 100644 index 000000000000..ede8442e2038 --- /dev/null +++ b/packages/astro/src/assets/svg/svgo.ts @@ -0,0 +1,10 @@ +import type { SvgOptimizer } from './types.js'; +import { optimize, type Config } from 'svgo'; + +/** SVG optimizer using [SVGO](https://svgo.dev/). */ +export function svgoOptimizer(config?: Config): SvgOptimizer { + return { + name: 'svgo', + optimize: (contents) => optimize(contents, config).data, + }; +} diff --git a/packages/astro/src/assets/svg/types.ts b/packages/astro/src/assets/svg/types.ts new file mode 100644 index 000000000000..637dde267429 --- /dev/null +++ b/packages/astro/src/assets/svg/types.ts @@ -0,0 +1,4 @@ +export interface SvgOptimizer { + name: string; + optimize: (contents: string) => string | Promise; +} diff --git a/packages/astro/src/assets/utils/svg.ts b/packages/astro/src/assets/svg/utils.ts similarity index 75% rename from packages/astro/src/assets/utils/svg.ts rename to packages/astro/src/assets/svg/utils.ts index 68193b14838b..19e4639b4100 100644 --- a/packages/astro/src/assets/utils/svg.ts +++ b/packages/astro/src/assets/svg/utils.ts @@ -1,31 +1,28 @@ -import { optimize } from 'svgo'; import { ELEMENT_NODE, TEXT_NODE, parse, renderSync } from 'ultrahtml'; import { AstroError, AstroErrorData } from '../../core/errors/index.js'; -import type { AstroConfig } from '../../types/public/config.js'; import type { SvgComponentProps } from '../runtime.js'; import { dropAttributes } from '../runtime.js'; import type { ImageMetadata } from '../types.js'; +import type { SvgOptimizer } from './types.js'; -function parseSvg({ +async function parseSvg({ path, contents, - svgoConfig, + svgOptimizer, }: { path: string; contents: string; - svgoConfig: AstroConfig['experimental']['svgo']; + svgOptimizer: SvgOptimizer | undefined; }) { let processedContents = contents; - if (svgoConfig) { + if (svgOptimizer) { try { - const config = typeof svgoConfig === 'boolean' ? undefined : svgoConfig; - const result = optimize(contents, config); - processedContents = result.data; + processedContents = await svgOptimizer.optimize(contents); } catch (cause) { throw new AstroError( { ...AstroErrorData.CannotOptimizeSvg, - message: AstroErrorData.CannotOptimizeSvg.message(path), + message: AstroErrorData.CannotOptimizeSvg.message(path, svgOptimizer.name), }, { cause }, ); @@ -58,20 +55,20 @@ function parseSvg({ return { attributes, body, styles }; } -export function makeSvgComponent( +export async function makeSvgComponent( meta: ImageMetadata, contents: Buffer | string, - svgoConfig: AstroConfig['experimental']['svgo'], -): string { + svgOptimizer: SvgOptimizer | undefined, +): Promise { const file = typeof contents === 'string' ? contents : contents.toString('utf-8'); const { attributes, body: children, styles, - } = parseSvg({ + } = await parseSvg({ path: meta.fsPath, contents: file, - svgoConfig, + svgOptimizer, }); const props: SvgComponentProps = { meta, @@ -89,20 +86,20 @@ export default createSvgComponent(${JSON.stringify(props)})`; * (attributes + inner HTML body) without generating any module code. * @internal Used by the asset pipeline for content-collection SVG images. */ -export function parseSvgComponentData( +export async function parseSvgComponentData( meta: ImageMetadata, contents: Buffer | string, - svgoConfig: AstroConfig['experimental']['svgo'], -): { attributes: Record; children: string; styles: string[] } { + svgOptimizer: SvgOptimizer | undefined, +): Promise<{ attributes: Record; children: string; styles: string[] }> { const file = typeof contents === 'string' ? contents : contents.toString('utf-8'); const { attributes, body: children, styles, - } = parseSvg({ + } = await parseSvg({ path: meta.fsPath, contents: file, - svgoConfig, + svgOptimizer, }); return { attributes: dropAttributes(attributes), children, styles }; } diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index f1341cc51c42..76080f96cc07 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -36,7 +36,7 @@ import { hashTransform, propsToFilename } from './utils/hash.js'; import { emitImageMetadata } from './utils/node.js'; import { CONTENT_IMAGE_FLAG } from '../content/consts.js'; import { getProxyCode } from './utils/proxy.js'; -import { makeSvgComponent, parseSvgComponentData } from './utils/svg.js'; +import { makeSvgComponent, parseSvgComponentData } from './svg/utils.js'; import { createPlaceholderURL, stringifyPlaceholderURL } from './utils/url.js'; const assetRegex = new RegExp(`\\.(${VALID_INPUT_FORMATS.join('|')})`, 'i'); @@ -371,7 +371,11 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl }); // We know that the contents are present, as we only emit this property for SVG files return { - code: makeSvgComponent(imageMetadata, contents, settings.config.experimental.svgo), + code: await makeSvgComponent( + imageMetadata, + contents, + settings.config.experimental.svgOptimizer, + ), }; } // In SSR builds, any image loaded by the SSR environment could be reachable at @@ -389,10 +393,10 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl const contents = await fs.promises.readFile(imageMetadata.fsPath, { encoding: 'utf8', }); - const svgData = parseSvgComponentData( + const svgData = await parseSvgComponentData( imageMetadata, contents, - settings.config.experimental.svgo, + settings.config.experimental.svgOptimizer, ); const metadataWithSvg = { ...imageMetadata, __svgData: svgData }; return { diff --git a/packages/astro/src/config/entrypoint.ts b/packages/astro/src/config/entrypoint.ts index 5d9292cff366..cd252d2b6fa2 100644 --- a/packages/astro/src/config/entrypoint.ts +++ b/packages/astro/src/config/entrypoint.ts @@ -12,6 +12,7 @@ export { validateConfig } from '../core/config/validate.js'; export { envField } from '../env/config.js'; export { defineConfig, getViteConfig } from './index.js'; export { sessionDrivers } from '../core/session/drivers.js'; +export { svgoOptimizer } from '../assets/svg/svgo.js'; export { logHandlers } from '../core/logger/handlers.js'; /** diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index 59d35af83b0d..fb027f8c1ff3 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -8,7 +8,6 @@ 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 * as z from 'zod/v4'; import { FontFamilySchema } from '../../../assets/fonts/config.js'; import { EnvSchema } from '../../../env/schema.js'; @@ -16,6 +15,7 @@ import type { AstroUserConfig, ViteUserConfig } from '../../../types/public/conf import { allowedDirectivesSchema, cspAlgorithmSchema, cspHashSchema } from '../../csp/config.js'; import { CacheSchema, RouteRulesSchema } from '../../cache/config.js'; import { SessionSchema } from '../../session/config.js'; +import { SvgOptimizerSchema } from '../../../assets/svg/config.js'; // The below types are required boilerplate to work around a Zod issue since v3.21.2. Since that version, // Zod's compiled TypeScript would "simplify" certain values to their base representation, causing references @@ -109,7 +109,6 @@ export const ASTRO_CONFIG_DEFAULTS = { clientPrerender: false, contentIntellisense: false, chromeDevtoolsWorkspace: false, - svgo: false, rustCompiler: false, queuedRendering: { enabled: false, @@ -538,10 +537,7 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.chromeDevtoolsWorkspace), - svgo: z - .union([z.boolean(), z.custom((value) => value && typeof value === 'object')]) - .optional() - .default(ASTRO_CONFIG_DEFAULTS.experimental.svgo), + svgOptimizer: SvgOptimizerSchema.optional(), cache: CacheSchema.optional(), routeRules: RouteRulesSchema.optional(), rustCompiler: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.rustCompiler), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 44bdc464a93a..b6edb6804ca2 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -610,13 +610,13 @@ export const UnsupportedImageConversion = { /** * @docs - * @message An error occurred while optimizing the SVG file with SVGO. + * @message An error occurred while optimizing the SVG file with the optimizer. */ export const CannotOptimizeSvg = { name: 'CannotOptimizeSvg', title: 'Cannot optimize SVG', - message: (path: string) => `An error occurred while optimizing SVG file "${path}" with SVGO.`, - hint: 'Review the included SVGO error message provided for guidance.', + message: (path: string, name: string) => `An error occurred while optimizing SVG file "${path}" with the "${name}" optimizer.`, + hint: 'Review the included error message provided for guidance.', } satisfies ErrorData; /** diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 6bd06ad46169..c8478bccd945 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -8,7 +8,6 @@ import type { Smartypants, SyntaxHighlightConfigType, } from '@astrojs/markdown-remark'; -import type { Config as SvgoConfig } from 'svgo'; import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite'; import type { FontFamily, FontProvider } from '../../assets/fonts/types.js'; import type { ImageFit, ImageLayout } from '../../assets/types.js'; @@ -25,6 +24,7 @@ import type { } from '../../core/session/types.js'; import type { EnvSchema } from '../../env/schema.js'; import type { AstroIntegration } from './integrations.js'; +import type { SvgOptimizer } from '../../assets/svg/types.js'; import type { LoggerHandlerConfig } from '../../core/logger/config.js'; export type Locales = (string | { codes: [string, ...string[]]; path: string })[]; @@ -35,6 +35,8 @@ export type { CspAlgorithm, CspHash }; export type { RemotePattern }; +export type { SvgOptimizer }; + export type CspStyleDirective = { hashes?: CspHash[]; resources?: string[] }; export type CspScriptDirective = { hashes?: CspHash[]; @@ -2854,14 +2856,12 @@ export interface AstroUserConfig< chromeDevtoolsWorkspace?: boolean; /** - * @name experimental.svgo - * @type {boolean | SvgoConfig} - * @default `false` + * @name experimental.svgOptimizer + * @type {SvgOptimizer} + * @default `undefined` + * @version 6.2.0 * @description - * Enable SVG optimization using SVGO during build time. - * - * Set to `true` to enable optimization with default settings, or pass a configuration - * object to customize SVGO behavior. + * Enable SVG optimization at build time. * * When enabled, all imported SVG files will be optimized for smaller file sizes * and better performance while maintaining visual quality. @@ -2870,32 +2870,14 @@ export interface AstroUserConfig< * { * experimental: { * // Enable with defaults - * svgo: true - * } - * } - * ``` - * - * To customize optimization, pass a [SVGO configuration object](https://svgo.dev/): - * - * ```js - * { - * experimental: { - * svgo: { - * plugins: [ - * 'preset-default', - * { - * name: 'removeViewBox', - * active: false - * } - * ] - * } + * svgOptimizer: svgoOptimizer() * } * } * ``` * - * See the [experimental SVGO optimization docs](https://docs.astro.build/en/reference/experimental-flags/svg-optimization/) for more information. + * See the [experimental SVG optimization docs](https://docs.astro.build/en/reference/experimental-flags/svg-optimization/) for more information. */ - svgo?: boolean | SvgoConfig; + svgOptimizer?: SvgOptimizer; /** * @name experimental.cache diff --git a/packages/astro/test/core-image-svg.test.ts b/packages/astro/test/core-image-svg.test.ts index babf6c03da97..cec1f3215d9d 100644 --- a/packages/astro/test/core-image-svg.test.ts +++ b/packages/astro/test/core-image-svg.test.ts @@ -3,6 +3,7 @@ import { Writable } from 'node:stream'; import { after, before, describe, it } from 'node:test'; import * as cheerio from 'cheerio'; import { AstroLogger } from '../dist/core/logger/core.js'; +import { svgoOptimizer } from '../dist/config/entrypoint.js'; import { type DevServer, type Fixture, loadFixture } from './test-utils.ts'; describe('astro:assets - SVG Components', () => { @@ -158,14 +159,14 @@ describe('astro:assets - SVG Components', () => { optimizedFixture = await loadFixture({ root: './fixtures/core-image-svg/', experimental: { - svgo: { + svgOptimizer: svgoOptimizer({ plugins: [ 'preset-default', { name: 'removeViewBox', }, ], - }, + }), }, }); diff --git a/packages/astro/test/types/schemas.ts b/packages/astro/test/types/schemas.ts index 3f84ba2a947a..c15c3b409095 100644 --- a/packages/astro/test/types/schemas.ts +++ b/packages/astro/test/types/schemas.ts @@ -7,6 +7,8 @@ import type { CacheSchema, RouteRulesSchema } from '../../dist/core/cache/config import type { CacheProviderConfig, RouteRules } from '../../dist/core/cache/types.js'; import type { SessionDriverConfigSchema } from '../../dist/core/session/config.js'; import type { SessionDriverConfig } from '../../dist/core/session/types.js'; +import type { SvgOptimizer } from '../../dist/assets/svg/types.js'; +import type { SvgOptimizerSchema } from '../../dist/assets/svg/config.js'; describe('fonts', () => { it('FontFamily type matches FontFamilySchema', () => { @@ -36,3 +38,9 @@ describe('session', () => { expectTypeOf>().toEqualTypeOf(); }); }); + +describe('svgOptimizer', () => { + it('SvgOptimizer type matches SvgOptimizerSchema', () => { + expectTypeOf>().toEqualTypeOf(); + }); +});