Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/brown-beds-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'astro': minor
---

Adds a new optional `getRemoteSize()` method to the Image Service API.

Previously, `inferRemoteSize()` had a fixed implementation that fetched the entire image to determine its dimensions.
With this new helper function that extends `inferRemoteSize()`, you can now override or extend how remote image metadata is retrieved.

This enables use cases such as:
- Caching: Storing image dimensions in a database or local cache to avoid redundant network requests.
- Provider APIs: Using a specific image provider's API (like Cloudinary or Vercel) to get dimensions without downloading the file.

For example, you can add a simple cache layer to your existing image service:

```js
const cache = new Map();

const myService = {
...baseService,
async getRemoteSize(url, imageConfig) {
if (cache.has(url)) return cache.get(url);

const result = await baseService.getRemoteSize(url, imageConfig);
cache.set(url, result);
return result;
}
};
```

See the [Image Services API reference documentation](https://v6.docs.astro.build/en/reference/image-service-reference/#getremotesize) for more information.
4 changes: 3 additions & 1 deletion packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export async function getImage(
delete resolvedOptions.inferSize; // Delete so it doesn't end up in the attributes

if (isRemoteImage(resolvedOptions.src) && isRemotePath(resolvedOptions.src)) {
const result = await inferRemoteSize(resolvedOptions.src); // Directly probe the image URL
const getRemoteSize = (url: string) =>
service.getRemoteSize?.(url, imageConfig) ?? inferRemoteSize(url);
const result = await getRemoteSize(resolvedOptions.src); // Directly probe the image URL
resolvedOptions.width ??= result.width;
resolvedOptions.height ??= result.height;
originalWidth = result.width;
Expand Down
15 changes: 15 additions & 0 deletions packages/astro/src/assets/services/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import type { AstroConfig } from '../../types/public/config.js';
import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
import type {
ImageFit,
ImageMetadata,
ImageOutputFormat,
ImageTransform,
UnresolvedSrcSetValue,
} from '../types.js';
import { isESMImportedImage, isRemoteImage } from '../utils/imageKind.js';
import { inferRemoteSize } from '../utils/remoteProbe.js';

export type ImageService = LocalImageService | ExternalImageService;

Expand Down Expand Up @@ -77,6 +79,16 @@ interface SharedServiceProps<T extends Record<string, any> = Record<string, any>
options: ImageTransform,
imageConfig: ImageConfig<T>,
) => ImageTransform | Promise<ImageTransform>;
/**
* Return the dimensions of a remote image.
*
* This is used to infer the width and height of an image from its URL,
* allowing the service to provide necessary metadata when it's not available locally.
*/
getRemoteSize?: (
url: string,
imageConfig: ImageConfig<T>,
) => Omit<ImageMetadata, 'src' | 'fsPath'> | Promise<Omit<ImageMetadata, 'src' | 'fsPath'>>;
}

export type ExternalImageService<T extends Record<string, any> = Record<string, any>> =
Expand Down Expand Up @@ -405,6 +417,9 @@ export const baseService: Omit<LocalImageService, 'transform'> = {

return transform;
},
getRemoteSize(url, _imageConfig) {
return inferRemoteSize(url);
},
};

/**
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/assets/services/sharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const sharpService: LocalImageService<SharpImageServiceConfig> = {
parseURL: baseService.parseURL,
getHTMLAttributes: baseService.getHTMLAttributes,
getSrcSet: baseService.getSrcSet,
getRemoteSize: baseService.getRemoteSize,
async transform(inputBuffer, transformOptions, config) {
if (!sharp) sharp = await loadSharp();
const transform: BaseServiceTransform = transformOptions as BaseServiceTransform;
Expand Down
12 changes: 10 additions & 2 deletions packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,18 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
handler() {
return {
code: `
export { getConfiguredImageService, isLocalService } from "astro/assets";
import { getConfiguredImageService as _getConfiguredImageService } from "astro/assets";
export { isLocalService } from "astro/assets";
import { getImage as getImageInternal } from "astro/assets";
export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro";
export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro";
export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js";
import { inferRemoteSize as inferRemoteSizeInternal } from "astro/assets/utils/inferRemoteSize.js";

export { default as Font } from "astro/components/Font.astro";
export * from "${RUNTIME_VIRTUAL_MODULE_ID}";

export const getConfiguredImageService = _getConfiguredImageService;

export const viteFSConfig = ${JSON.stringify(resolvedConfig.server.fs ?? {})};

export const safeModulePaths = new Set(${JSON.stringify(
Expand All @@ -183,6 +186,11 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
enumerable: false,
configurable: true,
});
export const inferRemoteSize = async (url) => {
const service = await _getConfiguredImageService()

return service.getRemoteSize?.(url, imageConfig) ?? inferRemoteSizeInternal(url)
}
// This is used by the @astrojs/node integration to locate images.
// It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel)
// new URL("dist/...") is interpreted by the bundler as a signal to include that directory
Expand Down
35 changes: 28 additions & 7 deletions packages/astro/test/core-image-layout.test.js
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ describe('astro:image:layout', () => {
describe('local image service', () => {
/** @type {import('./test-utils').DevServer} */
let devServer;
const walrusImagePath =
'https://images.unsplash.com/photo-1690941380217-24dfa9a1d21f?q=80&w=1476&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D';
const imageScale = 2;

before(async () => {
fixture = await loadFixture({
root: './fixtures/core-image-layout/',
image: {
service: testImageService({ foo: 'bar' }),
service: testImageService({
foo: 'bar',
transform: { path: walrusImagePath, scale: imageScale },
}),
domains: ['avatars.githubusercontent.com'],
},
});
Expand Down Expand Up @@ -224,13 +230,14 @@ describe('astro:image:layout', () => {
});

describe('remote images', () => {
let $;
before(async () => {
let res = await fixture.fetch('/remote');
let html = await res.text();
$ = cheerio.load(html);
});

describe('srcset', () => {
let $;
before(async () => {
let res = await fixture.fetch('/remote');
let html = await res.text();
$ = cheerio.load(html);
});
it('has srcset', () => {
let $img = $('#constrained img');
assert.ok($img.attr('srcset'));
Expand Down Expand Up @@ -263,6 +270,20 @@ describe('astro:image:layout', () => {
assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048, 2560]);
});
});

describe('inferSize', () => {
it('default inferSize works', () => {
let $img = $('#infer-size img');
assert.equal($img.attr('width'), '2670');
assert.equal($img.attr('height'), '1780');
});

it('custom inferSize works', () => {
let $img = $('#infer-size-custom img');
assert.equal($img.attr('width'), (1476 * imageScale).toString());
assert.equal($img.attr('height'), (978 * imageScale).toString());
});
});
});

describe('picture component', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { Image, Picture } from "astro:assets";

const penguin = "https://images.unsplash.com/photo-1670392957807-b0504fc5160a?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
const walrus = "https://images.unsplash.com/photo-1690941380217-24dfa9a1d21f?q=80&w=1476&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"

---

Expand All @@ -23,3 +24,10 @@ const penguin = "https://images.unsplash.com/photo-1670392957807-b0504fc5160a?q=
<Image src={penguin} alt="a penguin" width={800} height={600} layout="full-width"/>
</div>

<div id="infer-size">
<Image src={penguin} alt="a penguin" inferSize={true}/>
</div>

<div id="infer-size-custom">
<Image src={walrus} alt="a walrus" inferSize={true}/>
</div>
12 changes: 11 additions & 1 deletion packages/astro/test/test-image-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { baseService } from '../dist/assets/services/service.js';

/**
* stub image service that returns images as-is without optimization
* @param {{ foo?: string }} [config]
* @param {{ foo?: string, transform?: { path: string, scale: number } }} [config]
*/
export function testImageService(config = {}) {
return {
Expand Down Expand Up @@ -32,4 +32,14 @@ export default {
format: transform.format,
};
},
async getRemoteSize(url, serviceConfig) {
const baseSize = await baseService.getRemoteSize(url, serviceConfig);

if (serviceConfig.service.config.transform?.path === url) {
const scale = serviceConfig.service.config.transform.scale;
return { ...baseSize, width: baseSize.width * scale, height: baseSize.height * scale };
}

return baseSize;
},
};