Skip to content

Commit

Permalink
Merge branch 'next' into feat/routes-resolved
Browse files Browse the repository at this point in the history
  • Loading branch information
florian-lefebvre authored Nov 20, 2024
2 parents 846c3a7 + 18a04c0 commit cd62268
Show file tree
Hide file tree
Showing 37 changed files with 2,213 additions and 685 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).
89 changes: 87 additions & 2 deletions .changeset/proud-terms-swim.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,90 @@
---
'astro': patch
'astro': minor
---

Adds experimental reponsive image support
Adds experimental support for automatic responsive images

This feature is experimental and may change in future versions. To enable it, set `experimental.responsiveImages` to `true` in your `astro.config.mjs` file.

```js title=astro.config.mjs
{
experimental: {
responsiveImages: true,
},
}
```

When this flag is enabled, you can pass a `layout` prop to any `<Image />` or `<Picture />` component to create a responsive image. When a layout is set, images have automatically generated `srcset` and `sizes` attributes based on the image's dimensions and the layout type. Images with `responsive` and `full-width` layouts will have styles applied to ensure they resize according to their container.

```astro
---
import { Image, Picture } from 'astro:assets';
import myImage from '../assets/my_image.png';
---
<Image src={myImage} alt="A description of my image." layout='responsive' width={800} height={600} />
<Picture src={myImage} alt="A description of my image." layout='full-width' formats={['avif', 'webp', 'jpeg']} />
```
This `<Image />` component will generate the following HTML output:
```html title=Output

<img
src="/_astro/my_image.hash3.webp"
srcset="/_astro/my_image.hash1.webp 640w,
/_astro/my_image.hash2.webp 750w,
/_astro/my_image.hash3.webp 800w,
/_astro/my_image.hash4.webp 828w,
/_astro/my_image.hash5.webp 1080w,
/_astro/my_image.hash6.webp 1280w,
/_astro/my_image.hash7.webp 1600w"
alt="A description of my image"
sizes="(min-width: 800px) 800px, 100vw"
loading="lazy"
decoding="async"
fetchpriority="auto"
width="800"
height="600"
style="--w: 800; --h: 600; --fit: cover; --pos: center;"
data-astro-image="responsive"
>
```

#### Responsive image properties

These are additional properties available to the `<Image />` and `<Picture />` components when responsive images are enabled:

- `layout`: The layout type for the image. Can be `responsive`, `fixed`, `full-width` or `none`. Defaults to value of `image.experimentalLayout`.
- `fit`: Defines how the image should be cropped if the aspect ratio is changed. Values match those of CSS `object-fit`. Defaults to `cover`, or the value of `image.experimentalObjectFit` if set.
- `position`: Defines the position of the image crop if the aspect ratio is changed. Values match those of CSS `object-position`. Defaults to `center`, or the value of `image.experimentalObjectPosition` if set.
- `priority`: If set, eagerly loads the image. Otherwise images will be lazy-loaded. Use this for your largest above-the-fold image. Defaults to `false`.

#### Default responsive image settings

You can enable responsive images for all `<Image />` and `<Picture />` components by setting `image.experimentalLayout` with a default value. This can be overridden by the `layout` prop on each component.

**Example:**
```js title=astro.config.mjs
{
image: {
// Used for all `<Image />` and `<Picture />` components unless overridden
experimentalLayout: 'responsive',
},
experimental: {
responsiveImages: true,
},
}
```

```astro
---
import { Image } from 'astro:assets';
import myImage from '../assets/my_image.png';
---
<Image src={myImage} alt="This will use responsive layout" width={800} height={600} />
<Image src={myImage} alt="This will use full-width layout" layout="full-width" />
<Image src={myImage} alt="This will disable responsive images" layout="none" />
```

For a complete overview, and to give feedback on this experimental API, see the [Responsive Images RFC](https://github.com/withastro/roadmap/blob/responsive-images/proposals/0053-responsive-images.md).
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
1 change: 1 addition & 0 deletions packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export async function getImage(
resolvedOptions.fetchpriority ??= 'auto';
}
delete resolvedOptions.priority;
delete resolvedOptions.densities;
}

const validatedOptions = service.validateOptions
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)})`;
}
Loading

0 comments on commit cd62268

Please sign in to comment.