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 eafabab2b84b..0c75465a5b7d 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -101,14 +101,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 303c40ee7a9b..88f95a2388a0 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.0",
"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.2",
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 e22bada898e4..cdc4f5355fd1 100644
--- a/packages/astro/src/assets/services/service.ts
+++ b/packages/astro/src/assets/services/service.ts
@@ -3,7 +3,7 @@ import { isRemotePath, joinPaths } from '../../core/path.js';
import type { AstroConfig } from '../../types/public/config.js';
import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
import type { ImageOutputFormat, 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;
@@ -142,7 +142,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 037fae725a97..b8f3cf41e59b 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 48af43339c96..c19b4d9559f4 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -95,6 +95,9 @@ export const ASTRO_CONFIG_DEFAULTS = {
experimental: {
clientPrerender: false,
contentIntellisense: false,
+ svg: {
+ mode: 'inline',
+ },
},
} satisfies AstroUserConfig & { server: { open: boolean } };
@@ -525,6 +528,24 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense),
+ 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 1e24f419408e..298807eb8a81 100644
--- a/packages/astro/src/types/public/config.ts
+++ b/packages/astro/src/types/public/config.ts
@@ -7,6 +7,7 @@ import type {
} from '@astrojs/markdown-remark';
import type { UserConfig as OriginalViteUserConfig, SSROptions as ViteSSROptions } from 'vite';
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';
@@ -1681,7 +1682,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.
@@ -1699,6 +1700,67 @@ export interface ViteUserConfig extends OriginalViteUserConfig {
* To use this feature with the Astro VS Code extension, you must also enable the `astro.content-intellisense` option in your VS Code settings. For editors using the Astro language server directly, pass the `contentIntellisense: true` initialization parameter to enable this feature.
*/
contentIntellisense?: 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.
+ *
+ * When using 'sprite' mode, it is important to note that the symbol definition will be included with the first instance of the SVG that is rendered. This means that if the first instance is hidden or removed from the DOM, all other references will break.
+ *
+ * This mode can be overridden by passing a `mode` prop to the imported SVG component.
+ *
+ * ```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