mirror of
https://github.com/withastro/astro.git
synced 2025-01-22 10:31:53 -05:00
feat(assets): Add property to image services to control which properties to use for hashing (#8984)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
parent
100b61ab5a
commit
26b1484e80
10 changed files with 86 additions and 9 deletions
5
.changeset/new-islands-lick.md
Normal file
5
.changeset/new-islands-lick.md
Normal file
|
@ -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.
|
|
@ -26,3 +26,4 @@ export const VALID_SUPPORTED_FORMATS = [
|
||||||
] as const;
|
] as const;
|
||||||
export const DEFAULT_OUTPUT_FORMAT = 'webp' 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 VALID_OUTPUT_FORMATS = ['avif', 'png', 'webp', 'jpeg', 'jpg', 'svg'] as const;
|
||||||
|
export const DEFAULT_HASH_PROPS = ['src', 'width', 'height', 'format', 'quality'];
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { isRemotePath } from '@astrojs/internal-helpers/path';
|
import { isRemotePath } from '@astrojs/internal-helpers/path';
|
||||||
import type { AstroConfig, AstroSettings } from '../@types/astro.js';
|
import type { AstroConfig, AstroSettings } from '../@types/astro.js';
|
||||||
import { AstroError, AstroErrorData } from '../core/errors/index.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 { isLocalService, type ImageService } from './services/service.js';
|
||||||
import type {
|
import type {
|
||||||
GetImageResult,
|
GetImageResult,
|
||||||
|
@ -114,10 +115,11 @@ export async function getImage(
|
||||||
globalThis.astroAsset.addStaticImage &&
|
globalThis.astroAsset.addStaticImage &&
|
||||||
!(isRemoteImage(validatedOptions.src) && imageURL === validatedOptions.src)
|
!(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) => ({
|
srcSets = srcSetTransforms.map((srcSet) => ({
|
||||||
transform: srcSet.transform,
|
transform: srcSet.transform,
|
||||||
url: globalThis.astroAsset.addStaticImage!(srcSet.transform),
|
url: globalThis.astroAsset.addStaticImage!(srcSet.transform, propsToHash),
|
||||||
descriptor: srcSet.descriptor,
|
descriptor: srcSet.descriptor,
|
||||||
attributes: srcSet.attributes,
|
attributes: srcSet.attributes,
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { AstroConfig } from '../../@types/astro.js';
|
import type { AstroConfig } from '../../@types/astro.js';
|
||||||
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
|
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
|
||||||
import { isRemotePath, joinPaths } from '../../core/path.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 { isESMImportedImage, isRemoteAllowed } from '../internal.js';
|
||||||
import type { ImageOutputFormat, ImageTransform, UnresolvedSrcSetValue } from '../types.js';
|
import type { ImageOutputFormat, ImageTransform, UnresolvedSrcSetValue } from '../types.js';
|
||||||
|
|
||||||
|
@ -100,6 +100,13 @@ export interface LocalImageService<T extends Record<string, any> = Record<string
|
||||||
transform: LocalImageTransform,
|
transform: LocalImageTransform,
|
||||||
imageConfig: ImageConfig<T>
|
imageConfig: ImageConfig<T>
|
||||||
) => Promise<{ data: Buffer; format: ImageOutputFormat }>;
|
) => 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 = {
|
export type BaseServiceTransform = {
|
||||||
|
@ -131,6 +138,7 @@ export type BaseServiceTransform = {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export const baseService: Omit<LocalImageService, 'transform'> = {
|
export const baseService: Omit<LocalImageService, 'transform'> = {
|
||||||
|
propertiesToHash: DEFAULT_HASH_PROPS,
|
||||||
validateOptions(options) {
|
validateOptions(options) {
|
||||||
// `src` is missing or is `undefined`.
|
// `src` is missing or is `undefined`.
|
||||||
if (!options.src || (typeof options.src !== 'string' && typeof options.src !== 'object')) {
|
if (!options.src || (typeof options.src !== 'string' && typeof options.src !== 'object')) {
|
||||||
|
|
|
@ -17,7 +17,7 @@ declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
var astroAsset: {
|
var astroAsset: {
|
||||||
imageService?: ImageService;
|
imageService?: ImageService;
|
||||||
addStaticImage?: ((options: ImageTransform) => string) | undefined;
|
addStaticImage?: ((options: ImageTransform, hashProperties: string[]) => string) | undefined;
|
||||||
staticImages?: AssetsGlobalStaticImagesList;
|
staticImages?: AssetsGlobalStaticImagesList;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,9 +16,20 @@ export function propsToFilename(transform: ImageTransform, hash: string) {
|
||||||
return `/${filename}_${hash}${outputExt}`;
|
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
|
// Extract the fields we want to hash
|
||||||
const { alt, class: className, style, widths, densities, ...rest } = transform;
|
const hashFields = propertiesToHash.reduce(
|
||||||
const hashFields = { ...rest, imageService };
|
(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));
|
return shorthash(deterministicString(hashFields));
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,7 +77,7 @@ export default function assets({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
globalThis.astroAsset.addStaticImage = (options) => {
|
globalThis.astroAsset.addStaticImage = (options, hashProperties) => {
|
||||||
if (!globalThis.astroAsset.staticImages) {
|
if (!globalThis.astroAsset.staticImages) {
|
||||||
globalThis.astroAsset.staticImages = new Map<
|
globalThis.astroAsset.staticImages = new Map<
|
||||||
string,
|
string,
|
||||||
|
@ -88,7 +88,11 @@ export default function assets({
|
||||||
const originalImagePath = (
|
const originalImagePath = (
|
||||||
isESMImportedImage(options.src) ? options.src.src : options.src
|
isESMImportedImage(options.src) ? options.src.src : options.src
|
||||||
).replace(settings.config.build.assetsPrefix || '', '');
|
).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 finalFilePath: string;
|
||||||
let transformsForPath = globalThis.astroAsset.staticImages.get(originalImagePath);
|
let transformsForPath = globalThis.astroAsset.staticImages.get(originalImagePath);
|
||||||
|
|
|
@ -934,6 +934,29 @@ describe('astro:image', () => {
|
||||||
|
|
||||||
expect(isReusingCache).to.be.true;
|
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', () => {
|
describe('dev ssr', () => {
|
||||||
|
|
22
packages/astro/test/fixtures/core-image-ssg/src/pages/imageDeduplication.astro
vendored
Normal file
22
packages/astro/test/fixtures/core-image-ssg/src/pages/imageDeduplication.astro
vendored
Normal file
|
@ -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>
|
|
@ -15,6 +15,7 @@ export function testImageService(config = {}) {
|
||||||
/** @type {import("../dist/@types/astro").LocalImageService} */
|
/** @type {import("../dist/@types/astro").LocalImageService} */
|
||||||
export default {
|
export default {
|
||||||
...baseService,
|
...baseService,
|
||||||
|
propertiesToHash: [...baseService.propertiesToHash, 'data-custom'],
|
||||||
getHTMLAttributes(options, serviceConfig) {
|
getHTMLAttributes(options, serviceConfig) {
|
||||||
options['data-service'] = 'my-custom-service';
|
options['data-service'] = 'my-custom-service';
|
||||||
if (serviceConfig.service.config.foo) {
|
if (serviceConfig.service.config.foo) {
|
||||||
|
|
Loading…
Reference in a new issue