diff --git a/.changeset/blue-socks-doubt.md b/.changeset/blue-socks-doubt.md
new file mode 100644
index 000000000000..638e22e0804d
--- /dev/null
+++ b/.changeset/blue-socks-doubt.md
@@ -0,0 +1,30 @@
+---
+'astro': minor
+---
+
+Adds experimental support for built-in SVG components.
+
+
+This feature allows you to import SVG files directly into your Astro project as components. By default, Astro will inline the SVG content into your HTML output.
+
+To enable this feature, set `experimental.svg` to `true` in your Astro config:
+
+```js
+{
+ experimental: {
+ svg: true,
+ },
+}
+```
+
+To use this feature, import an SVG file in your Astro project, passing any common SVG attributes to the imported component. Astro also provides a `size` attribute to set equal `height` and `width` properties:
+
+```astro
+---
+import Logo from './path/to/svg/file.svg';
+---
+
+
+```
+
+For a complete overview, and to give feedback on this experimental API, see the [Feature RFC](https://github.com/withastro/roadmap/pull/1035).
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index a2e4cf0eb9be..f9badff24526 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -103,14 +103,31 @@ declare module '*.webp' {
const metadata: ImageMetadata;
export default metadata;
}
-declare module '*.svg' {
- const metadata: ImageMetadata;
- export default metadata;
-}
declare module '*.avif' {
const metadata: ImageMetadata;
export default metadata;
}
+declare module '*.svg' {
+ type Props = {
+ /**
+ * Accesible, short-text description
+ *
+ * {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title|MDN Reference}
+ */
+ title?: string;
+ /**
+ * Shorthand for setting the `height` and `width` properties
+ */
+ size?: number | string;
+ /**
+ * Override the default rendering mode for SVGs
+ */
+ mode?: import('./dist/assets/utils/svg.js').SvgRenderMode
+ } & astroHTML.JSX.SVGAttributes
+
+ const Component: ((_props: Props) => any) & ImageMetadata;
+ export default Component;
+}
declare module 'astro:transitions' {
type TransitionModule = typeof import('./dist/virtual-modules/transitions.js');
diff --git a/packages/astro/package.json b/packages/astro/package.json
index d29b01932b5c..e0cf3eef4cc6 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -59,6 +59,7 @@
"./toolbar": "./dist/toolbar/index.js",
"./actions/runtime/*": "./dist/actions/runtime/*",
"./assets": "./dist/assets/index.js",
+ "./assets/runtime": "./dist/assets/runtime.js",
"./assets/utils": "./dist/assets/utils/index.js",
"./assets/utils/inferRemoteSize.js": "./dist/assets/utils/remoteProbe.js",
"./assets/endpoint/*": "./dist/assets/endpoint/*.js",
@@ -163,6 +164,7 @@
"shiki": "^1.22.2",
"tinyexec": "^0.3.1",
"tsconfck": "^3.1.4",
+ "ultrahtml": "^1.5.3",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.3",
"vite": "6.0.0-beta.6",
diff --git a/packages/astro/src/assets/runtime.ts b/packages/astro/src/assets/runtime.ts
new file mode 100644
index 000000000000..e48a7139f49c
--- /dev/null
+++ b/packages/astro/src/assets/runtime.ts
@@ -0,0 +1,102 @@
+import {
+ createComponent,
+ render,
+ spreadAttributes,
+ unescapeHTML,
+} from '../runtime/server/index.js';
+import type { SSRResult } from '../types/public/index.js';
+import type { ImageMetadata } from './types.js';
+
+export interface SvgComponentProps {
+ meta: ImageMetadata;
+ attributes: Record;
+ children: string;
+}
+
+/**
+ * Make sure these IDs are kept on the module-level so they're incremented on a per-page basis
+ */
+const ids = new WeakMap();
+let counter = 0;
+
+export function createSvgComponent({ meta, attributes, children }: SvgComponentProps) {
+ const rendered = new WeakSet();
+ const Component = createComponent((result, props) => {
+ let id;
+ if (ids.has(result)) {
+ id = ids.get(result)!;
+ } else {
+ counter += 1;
+ ids.set(result, counter);
+ id = counter;
+ }
+ id = `a:${id}`;
+
+ const {
+ title: titleProp,
+ viewBox,
+ mode,
+ ...normalizedProps
+ } = normalizeProps(attributes, props);
+ const title = titleProp ? unescapeHTML(`${titleProp}`) : '';
+
+ if (mode === 'sprite') {
+ // On the first render, include the symbol definition
+ let symbol: any = '';
+ if (!rendered.has(result.response)) {
+ // We only need the viewBox on the symbol definition, we can drop it everywhere else
+ symbol = unescapeHTML(`${children}`);
+ rendered.add(result.response);
+ }
+
+ return render``;
+ }
+
+ // Default to inline mode
+ return render``;
+ });
+
+ if (import.meta.env.DEV) {
+ // Prevent revealing that this is a component
+ makeNonEnumerable(Component);
+
+ // Maintaining the current `console.log` output for SVG imports
+ Object.defineProperty(Component, Symbol.for('nodejs.util.inspect.custom'), {
+ value: (_: any, opts: any, inspect: any) => inspect(meta, opts),
+ });
+ }
+
+ // Attaching the metadata to the component to maintain current functionality
+ return Object.assign(Component, meta);
+}
+
+type SvgAttributes = Record;
+
+/**
+ * Some attributes required for `image/svg+xml` are irrelevant when inlined in a `text/html` document. We can save a few bytes by dropping them.
+ */
+const ATTRS_TO_DROP = ['xmlns', 'xmlns:xlink', 'version'];
+const DEFAULT_ATTRS: SvgAttributes = { role: 'img' };
+
+export function dropAttributes(attributes: SvgAttributes) {
+ for (const attr of ATTRS_TO_DROP) {
+ delete attributes[attr];
+ }
+
+ return attributes;
+}
+
+function normalizeProps(attributes: SvgAttributes, { size, ...props }: SvgAttributes) {
+ if (size !== undefined && props.width === undefined && props.height === undefined) {
+ props.height = size;
+ props.width = size;
+ }
+
+ return dropAttributes({ ...DEFAULT_ATTRS, ...attributes, ...props });
+}
+
+function makeNonEnumerable(object: Record) {
+ for (const property in object) {
+ Object.defineProperty(object, property, { enumerable: false });
+ }
+}
diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts
index d84ec1728e8e..ee3bcb587f8f 100644
--- a/packages/astro/src/assets/services/service.ts
+++ b/packages/astro/src/assets/services/service.ts
@@ -8,7 +8,7 @@ import type {
ImageTransform,
UnresolvedSrcSetValue,
} from '../types.js';
-import { isESMImportedImage } from '../utils/imageKind.js';
+import { isESMImportedImage, isRemoteImage } from '../utils/imageKind.js';
import { isRemoteAllowed } from '../utils/remotePattern.js';
export type ImageService = LocalImageService | ExternalImageService;
@@ -151,7 +151,7 @@ export const baseService: Omit = {
propertiesToHash: DEFAULT_HASH_PROPS,
validateOptions(options) {
// `src` is missing or is `undefined`.
- if (!options.src || (typeof options.src !== 'string' && typeof options.src !== 'object')) {
+ if (!options.src || (!isRemoteImage(options.src) && !isESMImportedImage(options.src))) {
throw new AstroError({
...AstroErrorData.ExpectedImage,
message: AstroErrorData.ExpectedImage.message(
diff --git a/packages/astro/src/assets/utils/imageKind.ts b/packages/astro/src/assets/utils/imageKind.ts
index e3e1b3341a4b..87946364f0b4 100644
--- a/packages/astro/src/assets/utils/imageKind.ts
+++ b/packages/astro/src/assets/utils/imageKind.ts
@@ -1,7 +1,7 @@
import type { ImageMetadata, UnresolvedImageTransform } from '../types.js';
export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata {
- return typeof src === 'object';
+ return typeof src === 'object' || (typeof src === 'function' && 'src' in src);
}
export function isRemoteImage(src: ImageMetadata | string): src is string {
diff --git a/packages/astro/src/assets/utils/index.ts b/packages/astro/src/assets/utils/index.ts
index 69e7c88dc401..98044ac9fa1c 100644
--- a/packages/astro/src/assets/utils/index.ts
+++ b/packages/astro/src/assets/utils/index.ts
@@ -13,3 +13,4 @@ export {
} from './remotePattern.js';
export { hashTransform, propsToFilename } from './transformToPath.js';
export { inferRemoteSize } from './remoteProbe.js';
+export { makeSvgComponent } from './svg.js'
diff --git a/packages/astro/src/assets/utils/node/emitAsset.ts b/packages/astro/src/assets/utils/node/emitAsset.ts
index 42dd1681f974..79a5287f64ab 100644
--- a/packages/astro/src/assets/utils/node/emitAsset.ts
+++ b/packages/astro/src/assets/utils/node/emitAsset.ts
@@ -7,6 +7,7 @@ import type { ImageMetadata } from '../../types.js';
import { imageMetadata } from '../metadata.js';
type FileEmitter = vite.Rollup.EmitFile;
+type ImageMetadataWithContents = ImageMetadata & { contents?: Buffer };
export async function emitESMImage(
id: string | undefined,
@@ -15,7 +16,7 @@ export async function emitESMImage(
// FIX: in Astro 6, this function should not be passed in dev mode at all.
// Or rethink the API so that a function that throws isn't passed through.
fileEmitter?: FileEmitter,
-): Promise {
+): Promise {
if (!id) {
return undefined;
}
@@ -30,7 +31,7 @@ export async function emitESMImage(
const fileMetadata = await imageMetadata(fileData, id);
- const emittedImage: Omit = {
+ const emittedImage: Omit = {
src: '',
...fileMetadata,
};
@@ -42,6 +43,11 @@ export async function emitESMImage(
value: id,
});
+ // Attach file data for SVGs
+ if (fileMetadata.format === 'svg') {
+ emittedImage.contents = fileData;
+ }
+
// Build
let isBuild = typeof fileEmitter === 'function';
if (isBuild) {
@@ -71,7 +77,7 @@ export async function emitESMImage(
emittedImage.src = `/@fs` + prependForwardSlash(fileURLToNormalizedPath(url));
}
- return emittedImage as ImageMetadata;
+ return emittedImage as ImageMetadataWithContents;
}
function fileURLToNormalizedPath(filePath: URL): string {
diff --git a/packages/astro/src/assets/utils/svg.ts b/packages/astro/src/assets/utils/svg.ts
new file mode 100644
index 000000000000..70088ba64a7c
--- /dev/null
+++ b/packages/astro/src/assets/utils/svg.ts
@@ -0,0 +1,27 @@
+import { parse, renderSync } from 'ultrahtml';
+import type { ImageMetadata } from '../types.js';
+import type { SvgComponentProps } from '../runtime.js';
+import { dropAttributes } from '../runtime.js';
+
+function parseSvg(contents: string) {
+ const root = parse(contents);
+ const [{ attributes, children }] = root.children;
+ const body = renderSync({ ...root, children });
+
+ return { attributes, body };
+}
+
+export type SvgRenderMode = 'inline' | 'sprite';
+
+export function makeSvgComponent(meta: ImageMetadata, contents: Buffer | string, options?: { mode?: SvgRenderMode }) {
+ const file = typeof contents === 'string' ? contents : contents.toString('utf-8');
+ const { attributes, body: children } = parseSvg(file);
+ const props: SvgComponentProps = {
+ meta,
+ attributes: dropAttributes({ mode: options?.mode, ...attributes }),
+ children,
+ };
+
+ return `import { createSvgComponent } from 'astro/assets/runtime';
+export default createSvgComponent(${JSON.stringify(props)})`;
+}
diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts
index 8214ce6657f6..7cb04c1bde6a 100644
--- a/packages/astro/src/assets/vite-plugin-assets.ts
+++ b/packages/astro/src/assets/vite-plugin-assets.ts
@@ -18,6 +18,7 @@ import { isESMImportedImage } from './utils/imageKind.js';
import { emitESMImage } from './utils/node/emitAsset.js';
import { getProxyCode } from './utils/proxy.js';
import { hashTransform, propsToFilename } from './utils/transformToPath.js';
+import { makeSvgComponent } from './utils/svg.js';
const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
@@ -52,7 +53,7 @@ const addStaticImageFactory = (
let finalFilePath: string;
let transformsForPath = globalThis.astroAsset.staticImages.get(finalOriginalPath);
- let transformForHash = transformsForPath?.transforms.get(hash);
+ const transformForHash = transformsForPath?.transforms.get(hash);
// If the same image has already been transformed with the same options, we'll reuse the final path
if (transformsForPath && transformForHash) {
@@ -213,6 +214,12 @@ export default function assets({ settings }: { settings: AstroSettings }): vite.
});
}
+ if (settings.config.experimental.svg && /\.svg$/.test(id)) {
+ const { contents, ...metadata } = imageMetadata;
+ // We know that the contents are present, as we only emit this property for SVG files
+ return makeSvgComponent(metadata, contents!, { mode: settings.config.experimental.svg.mode });
+ }
+
// We can only reliably determine if an image is used on the server, as we need to track its usage throughout the entire build.
// Since you cannot use image optimization on the client anyway, it's safe to assume that if the user imported
// an image on the client, it should be present in the final build.
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index 67228cb0976c..af3dd82ea47a 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -96,6 +96,9 @@ export const ASTRO_CONFIG_DEFAULTS = {
clientPrerender: false,
contentIntellisense: false,
responsiveImages: false,
+ svg: {
+ mode: 'inline',
+ },
},
} satisfies AstroUserConfig & { server: { open: boolean } };
@@ -534,6 +537,24 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.responsiveImages),
+ svg: z.union([
+ z.boolean(),
+ z
+ .object({
+ mode: z
+ .union([z.literal('inline'), z.literal('sprite')])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.experimental.svg.mode),
+ })
+ ])
+ .optional()
+ .transform((svgConfig) => {
+ // Handle normalization of `experimental.svg` config boolean values
+ if (typeof svgConfig === 'boolean') {
+ return svgConfig ? ASTRO_CONFIG_DEFAULTS.experimental.svg : undefined;
+ }
+ return svgConfig;
+ }),
})
.strict(
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-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 220debb3be5f..6af76676aa15 100644
--- a/packages/astro/src/types/public/config.ts
+++ b/packages/astro/src/types/public/config.ts
@@ -8,6 +8,7 @@ import type {
import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
import type { ImageFit, ImageLayout } from '../../assets/types.js';
import type { RemotePattern } from '../../assets/utils/remotePattern.js';
+import type { SvgRenderMode } from '../../assets/utils/svg.js';
import type { AssetsPrefix } from '../../core/app/types.js';
import type { AstroConfigType } from '../../core/config/schema.js';
import type { REDIRECT_STATUS_CODES } from '../../core/constants.js';
@@ -1769,7 +1770,7 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
* @name experimental.contentIntellisense
* @type {boolean}
* @default `false`
- * @version 4.14.0
+ * @version 5.x
* @description
*
* Enables Intellisense features (e.g. code completion, quick hints) for your content collection entries in compatible editors.
@@ -1906,6 +1907,63 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
*/
responsiveImages?: boolean;
+
+ /**
+ * @docs
+ * @name experimental.svg
+ * @type {boolean|object}
+ * @default `undefined`
+ * @version 5.x
+ * @description
+ *
+ * This feature allows you to import SVG files directly into your Astro project. By default, Astro will inline the SVG content into your HTML output.
+ *
+ * To enable this feature, set `experimental.svg` to `true` in your Astro config:
+ *
+ * ```js
+ * {
+ * experimental: {
+ * svg: true,
+ * },
+ * }
+ * ```
+ *
+ * To use this feature, import an SVG file in your Astro project, passing any common SVG attributes to the imported component.
+ * Astro also provides a `size` attribute to set equal `height` and `width` properties:
+ *
+ * ```astro
+ * ---
+ * import Logo from './path/to/svg/file.svg';
+ * ---
+ *
+ *
+ * ```
+ *
+ * For a complete overview, and to give feedback on this experimental API,
+ * see the [Feature RFC](https://github.com/withastro/roadmap/pull/1035).
+ */
+ svg?: {
+ /**
+ * @docs
+ * @name experimental.svg.mode
+ * @type {string}
+ * @default 'inline'
+ *
+ * The default technique for handling imported SVG files. Astro will inline the SVG content into your HTML output if not specified.
+ *
+ * - `inline`: Astro will inline the SVG content into your HTML output.
+ * - `sprite`: Astro will generate a sprite sheet with all imported SVG files.
+ *
+ * ```astro
+ * ---
+ * import Logo from './path/to/svg/file.svg';
+ * ---
+ *
+ *
+ * ```
+ */
+ mode?: SvgRenderMode;
+ };
};
}
diff --git a/packages/astro/test/core-image-svg.test.js b/packages/astro/test/core-image-svg.test.js
new file mode 100644
index 000000000000..d6134aaf775c
--- /dev/null
+++ b/packages/astro/test/core-image-svg.test.js
@@ -0,0 +1,406 @@
+import assert from 'node:assert/strict';
+import { Writable } from 'node:stream';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import { Logger } from '../dist/core/logger/core.js';
+import { loadFixture } from './test-utils.js';
+
+describe('astro:assets - SVG Components', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ describe('dev', () => {
+ /** @type {import('./test-utils').DevServer} */
+ let devServer;
+ /** @type {Array<{ type: any, level: 'error', message: string; }>} */
+ let logs = [];
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/core-image-svg/',
+ });
+
+ devServer = await fixture.startDevServer({
+ logger: new Logger({
+ level: 'error',
+ dest: new Writable({
+ objectMode: true,
+ write(event, _, callback) {
+ logs.push(event);
+ callback();
+ },
+ }),
+ }),
+ });
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ describe('basics', () => {
+ let $;
+ before(async () => {
+ let res = await fixture.fetch('/');
+ let html = await res.text();
+ $ = cheerio.load(html, { xml: true });
+ });
+ it('Inlines the SVG by default', () => {
+ const $svgs = $('.inline svg');
+ assert.equal($svgs.length, 2);
+ $svgs.each(function () {
+ assert.equal($(this).attr('role'), 'img');
+ assert.equal(!!$(this).attr('mode'), false);
+ const $use = $(this).children('use');
+ assert.equal($use.length, 0);
+ })
+ });
+
+ it('Adds the