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"; +--- + + + + + +
+ a penguin + a penguin + a penguin + a penguin +
+ +
+ a penguin + a penguin +
+ + 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) {