Skip to content

Commit

Permalink
feat(assets): Add property to image services to control which propert…
Browse files Browse the repository at this point in the history
…ies to use for hashing (#8984)

Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
Princesseuh and sarah11918 authored Nov 8, 2023
1 parent 100b61a commit 26b1484
Show file tree
Hide file tree
Showing 10 changed files with 86 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/new-islands-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': minor
---

Adds a new property `propertiesToHash` to the Image Services API to allow specifying which properties of `getImage()` / `<Image />` / `<Picture />` 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.
1 change: 1 addition & 0 deletions packages/astro/src/assets/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
6 changes: 4 additions & 2 deletions packages/astro/src/assets/internal.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
}));
Expand Down
10 changes: 9 additions & 1 deletion packages/astro/src/assets/services/service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -100,6 +100,13 @@ export interface LocalImageService<T extends Record<string, any> = Record<string
transform: LocalImageTransform,
imageConfig: ImageConfig<T>
) => 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 = {
Expand Down Expand Up @@ -131,6 +138,7 @@ export type BaseServiceTransform = {
*
*/
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')) {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/assets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}
Expand Down
17 changes: 14 additions & 3 deletions packages/astro/src/assets/utils/transformToPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
);
return shorthash(deterministicString(hashFields));
}
8 changes: 6 additions & 2 deletions packages/astro/src/assets/vite-plugin-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions packages/astro/test/core-image.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
import { Image } from 'astro:assets';
import myImage from "../assets/penguin1.jpg";
---
<html>
<head>

</head>
<body>
<div id="all-the-same">
<Image src={myImage} alt="a penguin" />
<Image src={myImage} alt="a penguin" class="something" />
<Image src={myImage} alt="a penguin" id="something-else" class="something" />
<Image src={myImage} alt="a penguin" id="something-else" class="something" transition:animate={"none"} transition:name='' transition:persist style="color: red" />
</div>

<div id="use-data">
<Image src={myImage} alt="a penguin" />
<Image src={myImage} alt="a penguin" data-custom="value" />
</div>
</body>
</html>
1 change: 1 addition & 0 deletions packages/astro/test/test-image-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit 26b1484

Please sign in to comment.