diff --git a/.changeset/rare-deers-relax.md b/.changeset/rare-deers-relax.md new file mode 100644 index 000000000..5ab75d61c --- /dev/null +++ b/.changeset/rare-deers-relax.md @@ -0,0 +1,5 @@ +--- +'@astrojs/image': patch +--- + +Adding a unique hash to remote images built for SSG to ensure unique URLs are always de-duplicated diff --git a/packages/integrations/image/src/shorthash.ts b/packages/integrations/image/src/shorthash.ts new file mode 100644 index 000000000..99a691ac4 --- /dev/null +++ b/packages/integrations/image/src/shorthash.ts @@ -0,0 +1,67 @@ +/** + * shortdash - https://github.com/bibig/node-shorthash + * + * @license + * + * (The MIT License) + * + * Copyright (c) 2013 Bibig + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +const dictionary = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY'; +const binary = dictionary.length; + +// refer to: http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ +function bitwise(str: string) { + let hash = 0; + if (str.length === 0) return hash; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + hash = (hash << 5) - hash + ch; + hash = hash & hash; // Convert to 32bit integer + } + return hash; +} + +export function shorthash(text: string) { + let num: number; + let result = ''; + + let integer = bitwise(text); + const sign = integer < 0 ? 'Z' : ''; // It it's negative, start with Z, which isn't in the dictionary + + integer = Math.abs(integer); + + while (integer >= binary) { + num = integer % binary; + integer = Math.floor(integer / binary); + result = dictionary[num] + result; + } + + if (integer > 0) { + result = dictionary[integer] + result; + } + + return sign + result; +} diff --git a/packages/integrations/image/src/utils.ts b/packages/integrations/image/src/utils.ts index 44c338cf4..80dff1b6e 100644 --- a/packages/integrations/image/src/utils.ts +++ b/packages/integrations/image/src/utils.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { shorthash } from './shorthash.js'; import type { OutputFormat, TransformOptions } from './types'; export function isOutputFormat(value: string): value is OutputFormat { @@ -48,6 +49,11 @@ export function propsToFilename({ src, width, height, format }: TransformOptions const ext = path.extname(src); let filename = src.replace(ext, ''); + // for remote images, add a hash of the full URL to dedupe images with the same filename + if (isRemoteImage(src)) { + filename += `-${shorthash(src)}`; + } + if (width && height) { return `${filename}_${width}x${height}.${format}`; } else if (width) { diff --git a/packages/integrations/image/test/image-ssg.test.js b/packages/integrations/image/test/image-ssg.test.js index b0d12908c..3c5d4802e 100644 --- a/packages/integrations/image/test/image-ssg.test.js +++ b/packages/integrations/image/test/image-ssg.test.js @@ -58,16 +58,20 @@ describe('SSG images', function () { }); describe('Remote images', () => { + // Hard-coding in the test! This should never change since the hash is based + // on the static `src` string + const HASH = 'Z1iI4xW'; + it('includes src, width, and height attributes', () => { const image = $('#google'); - expect(image.attr('src')).to.equal('/_image/googlelogo_color_272x92dp_544x184.webp'); + expect(image.attr('src')).to.equal(`/_image/googlelogo_color_272x92dp-${HASH}_544x184.webp`); expect(image.attr('width')).to.equal('544'); expect(image.attr('height')).to.equal('184'); }); it('built the optimized image', () => { - verifyImage('_image/googlelogo_color_272x92dp_544x184.webp', { + verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_544x184.webp`, { width: 544, height: 184, type: 'webp', diff --git a/packages/integrations/image/test/picture-ssg.test.js b/packages/integrations/image/test/picture-ssg.test.js index 7740ad055..0da1daa1c 100644 --- a/packages/integrations/image/test/picture-ssg.test.js +++ b/packages/integrations/image/test/picture-ssg.test.js @@ -91,6 +91,10 @@ describe('SSG pictures', function () { }); describe('Remote images', () => { + // Hard-coding in the test! This should never change since the hash is based + // on the static `src` string + const HASH = 'Z1iI4xW'; + it('includes sources', () => { const sources = $('#google source'); @@ -102,38 +106,38 @@ describe('SSG pictures', function () { it('includes src, width, and height attributes', () => { const image = $('#google img'); - expect(image.attr('src')).to.equal('/_image/googlelogo_color_272x92dp_544x184.png'); + expect(image.attr('src')).to.equal(`/_image/googlelogo_color_272x92dp-${HASH}_544x184.png`); expect(image.attr('width')).to.equal('544'); expect(image.attr('height')).to.equal('184'); }); it('built the optimized image', () => { - verifyImage('_image/googlelogo_color_272x92dp_272x92.avif', { + verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_272x92.avif`, { width: 272, height: 92, type: 'avif', }); - verifyImage('_image/googlelogo_color_272x92dp_272x92.webp', { + verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_272x92.webp`, { width: 272, height: 92, type: 'webp', }); - verifyImage('_image/googlelogo_color_272x92dp_272x92.png', { + verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_272x92.png`, { width: 272, height: 92, type: 'png', }); - verifyImage('_image/googlelogo_color_272x92dp_544x184.avif', { + verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_544x184.avif`, { width: 544, height: 184, type: 'avif', }); - verifyImage('_image/googlelogo_color_272x92dp_544x184.webp', { + verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_544x184.webp`, { width: 544, height: 184, type: 'webp', }); - verifyImage('_image/googlelogo_color_272x92dp_544x184.png', { + verifyImage(`_image/googlelogo_color_272x92dp-${HASH}_544x184.png`, { width: 544, height: 184, type: 'png',