Skip to content

Commit

Permalink
feat(astro): add Built-in SVG component support (#12067)
Browse files Browse the repository at this point in the history
Co-authored-by: Sarah Rainsberger <[email protected]>
Co-authored-by: Emanuele Stoppa <[email protected]>
  • Loading branch information
3 people authored Nov 19, 2024
1 parent e246dc5 commit c48916c
Show file tree
Hide file tree
Showing 35 changed files with 2,121 additions and 678 deletions.
30 changes: 30 additions & 0 deletions .changeset/blue-socks-doubt.md
Original file line number Diff line number Diff line change
@@ -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';
---
<Logo size={24} />
```

For a complete overview, and to give feedback on this experimental API, see the [Feature RFC](https://github.com/withastro/roadmap/pull/1035).
25 changes: 21 additions & 4 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions packages/astro/src/assets/runtime.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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<SSRResult, number>();
let counter = 0;

export function createSvgComponent({ meta, attributes, children }: SvgComponentProps) {
const rendered = new WeakSet<Response>();
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(`<title>${titleProp}</title>`) : '';

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(`<symbol${spreadAttributes({ viewBox, id })}>${children}</symbol>`);
rendered.add(result.response);
}

return render`<svg${spreadAttributes(normalizedProps)}>${title}${symbol}<use href="#${id}" /></svg>`;
}

// Default to inline mode
return render`<svg${spreadAttributes({ viewBox, ...normalizedProps })}>${title}${unescapeHTML(children)}</svg>`;
});

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<string, any>;

/**
* 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<string, any>) {
for (const property in object) {
Object.defineProperty(object, property, { enumerable: false });
}
}
4 changes: 2 additions & 2 deletions packages/astro/src/assets/services/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -151,7 +151,7 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
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(
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/assets/utils/imageKind.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/assets/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export {
} from './remotePattern.js';
export { hashTransform, propsToFilename } from './transformToPath.js';
export { inferRemoteSize } from './remoteProbe.js';
export { makeSvgComponent } from './svg.js'
12 changes: 9 additions & 3 deletions packages/astro/src/assets/utils/node/emitAsset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<ImageMetadata | undefined> {
): Promise<ImageMetadataWithContents | undefined> {
if (!id) {
return undefined;
}
Expand All @@ -30,7 +31,7 @@ export async function emitESMImage(

const fileMetadata = await imageMetadata(fileData, id);

const emittedImage: Omit<ImageMetadata, 'fsPath'> = {
const emittedImage: Omit<ImageMetadataWithContents, 'fsPath'> = {
src: '',
...fileMetadata,
};
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions packages/astro/src/assets/utils/svg.ts
Original file line number Diff line number Diff line change
@@ -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)})`;
}
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 @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export const ASTRO_CONFIG_DEFAULTS = {
clientPrerender: false,
contentIntellisense: false,
responsiveImages: false,
svg: {
mode: 'inline',
},
},
} satisfies AstroUserConfig & { server: { open: boolean } };

Expand Down Expand Up @@ -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.`,
Expand Down
Loading

0 comments on commit c48916c

Please sign in to comment.