diff --git a/.changeset/new-islands-lick.md b/.changeset/new-islands-lick.md
new file mode 100644
index 000000000000..ba1c7b0516d4
--- /dev/null
+++ b/.changeset/new-islands-lick.md
@@ -0,0 +1,5 @@
+---
+'astro': minor
+---
+
+Adds a new property `propertiesToHash` to the Image Services API to allow specifying which properties of `getImage()` / `` / `` should be used for hashing the result files when doing local transformations. For most services, this will include properties such as `src`, `width` or `quality` that directly changes the content of the generated image.
diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts
index 687582b8ec43..15f9fe46fb24 100644
--- a/packages/astro/src/assets/consts.ts
+++ b/packages/astro/src/assets/consts.ts
@@ -26,3 +26,4 @@ export const VALID_SUPPORTED_FORMATS = [
] as const;
export const DEFAULT_OUTPUT_FORMAT = 'webp' as const;
export const VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;
+export const DEFAULT_HASH_PROPS = ['src', 'width', 'height', 'format', 'quality'];
diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts
index 1c26ac6b5dfa..ef628b69f4d0 100644
--- a/packages/astro/src/assets/internal.ts
+++ b/packages/astro/src/assets/internal.ts
@@ -1,6 +1,7 @@
import { isRemotePath } from '@astrojs/internal-helpers/path';
import type { AstroConfig, AstroSettings } from '../@types/astro.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import { DEFAULT_HASH_PROPS } from './consts.js';
import { isLocalService, type ImageService } from './services/service.js';
import type {
GetImageResult,
@@ -114,10 +115,11 @@ export async function getImage(
globalThis.astroAsset.addStaticImage &&
!(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src)
) {
- imageURL = globalThis.astroAsset.addStaticImage(validatedOptions);
+ const propsToHash = service.propertiesToHash ?? DEFAULT_HASH_PROPS;
+ imageURL = globalThis.astroAsset.addStaticImage(validatedOptions, propsToHash);
srcSets = srcSetTransforms.map((srcSet) => ({
transform: srcSet.transform,
- url: globalThis.astroAsset.addStaticImage!(srcSet.transform),
+ url: globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash),
descriptor: srcSet.descriptor,
attributes: srcSet.attributes,
}));
diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts
index 8d77442c7370..5a063d4670ae 100644
--- a/packages/astro/src/assets/services/service.ts
+++ b/packages/astro/src/assets/services/service.ts
@@ -1,7 +1,7 @@
import type { AstroConfig } from '../../@types/astro.js';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { isRemotePath, joinPaths } from '../../core/path.js';
-import { DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
+import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js';
import { isESMImportedImage, isRemoteAllowed } from '../internal.js';
import type { ImageOutputFormat, ImageTransform, UnresolvedSrcSetValue } from '../types.js';
@@ -100,6 +100,13 @@ export interface LocalImageService = Record
) => Promise<{ data: Buffer; format: ImageOutputFormat }>;
+
+ /**
+ * A list of properties that should be used to generate the hash for the image.
+ *
+ * Generally, this should be all the properties that can change the result of the image. By default, this is `src`, `width`, `height`, `quality`, and `format`.
+ */
+ propertiesToHash?: string[];
}
export type BaseServiceTransform = {
@@ -131,6 +138,7 @@ export type BaseServiceTransform = {
*
*/
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')) {
diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts
index c11f58b25e00..91d6ba1ff64b 100644
--- a/packages/astro/src/assets/types.ts
+++ b/packages/astro/src/assets/types.ts
@@ -17,7 +17,7 @@ declare global {
// eslint-disable-next-line no-var
var astroAsset: {
imageService?: ImageService;
- addStaticImage?: ((options: ImageTransform) => string) | undefined;
+ addStaticImage?: ((options: ImageTransform, hashProperties: string[]) => string) | undefined;
staticImages?: AssetsGlobalStaticImagesList;
};
}
diff --git a/packages/astro/src/assets/utils/transformToPath.ts b/packages/astro/src/assets/utils/transformToPath.ts
index 82c5cc2793e3..4738ef2a1d5e 100644
--- a/packages/astro/src/assets/utils/transformToPath.ts
+++ b/packages/astro/src/assets/utils/transformToPath.ts
@@ -16,9 +16,20 @@ export function propsToFilename(transform: ImageTransform, hash: string) {
return `/${filename}_${hash}${outputExt}`;
}
-export function hashTransform(transform: ImageTransform, imageService: string) {
+export function hashTransform(
+ transform: ImageTransform,
+ imageService: string,
+ propertiesToHash: string[]
+) {
// Extract the fields we want to hash
- const { alt, class: className, style, widths, densities, ...rest } = transform;
- const hashFields = { ...rest, imageService };
+ const hashFields = propertiesToHash.reduce(
+ (acc, prop) => {
+ // It's possible for `transform[prop]` here to be undefined, or null, but that's fine because it's still consistent
+ // between different transforms. (ex: every transform without a height will explicitly have a `height: undefined` property)
+ acc[prop] = transform[prop];
+ return acc;
+ },
+ { imageService } as Record
+ );
return shorthash(deterministicString(hashFields));
}
diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts
index 382d161fd76a..23e2924baefd 100644
--- a/packages/astro/src/assets/vite-plugin-assets.ts
+++ b/packages/astro/src/assets/vite-plugin-assets.ts
@@ -77,7 +77,7 @@ export default function assets({
return;
}
- globalThis.astroAsset.addStaticImage = (options) => {
+ globalThis.astroAsset.addStaticImage = (options, hashProperties) => {
if (!globalThis.astroAsset.staticImages) {
globalThis.astroAsset.staticImages = new Map<
string,
@@ -88,7 +88,11 @@ export default function assets({
const originalImagePath = (
isESMImportedImage(options.src) ? options.src.src : options.src
).replace(settings.config.build.assetsPrefix || '', '');
- const hash = hashTransform(options, settings.config.image.service.entrypoint);
+ const hash = hashTransform(
+ options,
+ settings.config.image.service.entrypoint,
+ hashProperties
+ );
let finalFilePath: string;
let transformsForPath = globalThis.astroAsset.staticImages.get(originalImagePath);
diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js
index fb7c7c828d16..bd2efc466915 100644
--- a/packages/astro/test/core-image.test.js
+++ b/packages/astro/test/core-image.test.js
@@ -934,6 +934,29 @@ describe('astro:image', () => {
expect(isReusingCache).to.be.true;
});
+
+ describe('custom service in build', () => {
+ it('uses configured hashes properties', async () => {
+ await fixture.build();
+ const html = await fixture.readFile('/imageDeduplication/index.html');
+
+ const $ = cheerio.load(html);
+
+ const allTheSamePath = $('#all-the-same img')
+ .map((_, el) => $(el).attr('src'))
+ .get();
+
+ expect(allTheSamePath.every((path) => path === allTheSamePath[0])).to.equal(true);
+
+ const useCustomHashProperty = $('#use-data img')
+ .map((_, el) => $(el).attr('src'))
+ .get();
+ expect(useCustomHashProperty.every((path) => path === useCustomHashProperty[0])).to.equal(
+ false
+ );
+ expect(useCustomHashProperty[1]).to.not.equal(allTheSamePath[0]);
+ });
+ });
});
describe('dev ssr', () => {
diff --git a/packages/astro/test/fixtures/core-image-ssg/src/pages/imageDeduplication.astro b/packages/astro/test/fixtures/core-image-ssg/src/pages/imageDeduplication.astro
new file mode 100644
index 000000000000..200bdae39ec5
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-ssg/src/pages/imageDeduplication.astro
@@ -0,0 +1,22 @@
+---
+import { Image } from 'astro:assets';
+import myImage from "../assets/penguin1.jpg";
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/astro/test/test-image-service.js b/packages/astro/test/test-image-service.js
index bcf623caa809..35a5fa2c8625 100644
--- a/packages/astro/test/test-image-service.js
+++ b/packages/astro/test/test-image-service.js
@@ -15,6 +15,7 @@ export function testImageService(config = {}) {
/** @type {import("../dist/@types/astro").LocalImageService} */
export default {
...baseService,
+ propertiesToHash: [...baseService.propertiesToHash, 'data-custom'],
getHTMLAttributes(options, serviceConfig) {
options['data-service'] = 'my-custom-service';
if (serviceConfig.service.config.foo) {