diff --git a/README.md b/README.md index 24fd5289..63cedf67 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Depending on your module system, using @imgix/js-core is done a few different wa ### CommonJS ```js -const ImgixClient = require("@imgix/js-core"); +const ImgixClient = require('@imgix/js-core'); const client = new ImgixClient({ domain: 'testing.imgix.net', @@ -73,7 +73,7 @@ console.log(url); // => "https://testing.imgix.net/users/1.png?w=400&h=300&s=… ### ES6 Modules ```js -import ImgixClient from "@imgix/js-core"; +import ImgixClient from '@imgix/js-core'; const client = new ImgixClient({ domain: 'testing.imgix.net', @@ -144,6 +144,8 @@ https://testing.imgix.net/folder/image.jpg?w=1000&ixlib=js-... - [**`minWidth`**](#minimum-and-maximum-width-ranges) - [**`maxWidth`**](#minimum-and-maximum-width-ranges) - [**`disableVariableQuality`**](#variable-qualities) + - [**`devicePixelRatios`**](#fixed-image-rendering) + - [**`variableQualities`**](#variable-qualities) @@ -210,6 +212,38 @@ https://testing.imgix.net/image.jpg?h=800&ar=3%3A2&fit=crop&dpr=5&s=7c4b8adb733d +This library generate by default `1` to `5` dpr `srcset`. +You can control generated target ratios with `devicePixelRatios` parameters. + +```js +const client = new ImgixClient({ + domain: 'testing.imgix.net', + secureURLToken: 'my-token', + includeLibraryParam: false, +}); + +const srcset = client.buildSrcSet( + 'image.jpg', + { + h: 800, + ar: '3:2', + fit: 'crop', + }, + { + devicePixelRatios: [1, 2], + }, +); + +console.log(srcset); +``` + +Will result in a smaller srcset. + +```html +https://testing.imgix.net/image.jpg?h=800&ar=3%3A2&fit=crop&dpr=1&s=3d754a157458402fd3e26977107ade74 1x, +https://testing.imgix.net/image.jpg?h=800&ar=3%3A2&fit=crop&dpr=2&s=a984ad1a81d24d9dd7d18195d5262c82 2x +``` + For more information to better understand `srcset`, we highly recommend [Eric Portis' "Srcset and sizes" article](https://ericportis.com/posts/2014/srcset-sizes/) which goes into depth about the subject. #### Custom Widths @@ -225,7 +259,7 @@ const client = new ImgixClient({ const srcset = client.buildSrcSet( 'image.jpg', {}, - { widths: [100, 500, 1000, 1800] } + { widths: [100, 500, 1000, 1800] }, ); console.log(srcset); @@ -286,7 +320,7 @@ const client = new ImgixClient({ const srcset = client.buildSrcSet( 'image.jpg', {}, - { minWidth: 500, maxWidth: 2000 } + { minWidth: 500, maxWidth: 2000 }, ); console.log(srcset); @@ -327,9 +361,11 @@ const client = new ImgixClient({ }); const srcset = client.buildSrcSet('image.jpg', { w: 100 }); + +console.log(srcset); ``` -will generate a srcset with the following `q` to `dpr` mapping: +Will generate a srcset with the following `q` to `dpr` mapping: ```html https://testing.imgix.net/image.jpg?w=100&dpr=1&q=75 1x, @@ -339,6 +375,33 @@ https://testing.imgix.net/image.jpg?w=100&dpr=4&q=23 4x, https://testing.imgix.net/image.jpg?w=100&dpr=5&q=20 5x ``` +Quality parameters is overridable for each `dpr` by passing `variableQualities` parameters. + +```js +const client = new ImgixClient({ + domain: 'testing.imgix.net', + includeLibraryParam: false, +}); + +const srcset = client.buildSrcSet( + 'image.jpg', + { w: 100 }, + { variableQualities: { 1: 45, 2: 30, 3: 20, 4: 15, 5: 10 } }, +); + +console.log(srcset); +``` + +Will generate the following custom `q` to `dpr` mapping: + +```html +https://testing.imgix.net/image.jpg?w=100&dpr=1&q=45 1x, +https://testing.imgix.net/image.jpg?w=100&dpr=2&q=30 2x, +https://testing.imgix.net/image.jpg?w=100&dpr=3&q=20 3x, +https://testing.imgix.net/image.jpg?w=100&dpr=4&q=15 4x, +https://testing.imgix.net/image.jpg?w=100&dpr=5&q=10 5x +``` + ### Web Proxy Sources If you are using a [Web Proxy Source](https://docs.imgix.com/setup/creating-sources/web-proxy), all you need to do is pass the full image URL you would like to proxy to `@imgix/js-core` as the path, and include a `secureURLToken` when creating the client. `@imgix/js-core` will then encode this full URL into a format that imgix will understand, thus creating a proxy URL for you. @@ -376,6 +439,6 @@ new ImgixClient({ npm test ``` - ## License + [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fimgix%2Fimgix-core-js.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fimgix%2Fimgix-core-js?ref=badge_large) diff --git a/package-lock.json b/package-lock.json index 1313b5cb..1549074f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@imgix/js-core", - "version": "v3.2.1", + "version": "3.2.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@imgix/js-core", - "version": "v3.2.1", + "version": "3.2.2", "license": "BSD-2-Clause", "dependencies": { "js-base64": "~3.7.0", @@ -9142,6 +9142,11 @@ "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, "engines": { "node": ">=0.10.0" } diff --git a/src/constants.js b/src/constants.js index 48bb3a49..1732c120 100644 --- a/src/constants.js +++ b/src/constants.js @@ -18,6 +18,8 @@ export const DPR_QUALITIES = { 5: 20, }; +export const DEFAULT_DPR = [1, 2, 3, 4, 5]; + export const DEFAULT_OPTIONS = { domain: null, useHTTPS: true, diff --git a/src/index.js b/src/index.js index 636e2137..a8b54ff1 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ import { DOMAIN_REGEX, DEFAULT_OPTIONS, DPR_QUALITIES, + DEFAULT_DPR, } from './constants.js'; import { @@ -14,6 +15,8 @@ import { validateAndDestructureOptions, validateVariableQuality, validateWidthTolerance, + validateDevicePixelRatios, + validateVariableQualities, } from './validators.js'; export default class ImgixClient { @@ -191,18 +194,29 @@ export default class ImgixClient { } _buildDPRSrcSet(path, params, options) { - const targetRatios = [1, 2, 3, 4, 5]; + if (options.devicePixelRatios) { + validateDevicePixelRatios(options.devicePixelRatios); + } + + const targetRatios = options.devicePixelRatios || DEFAULT_DPR; + const disableVariableQuality = options.disableVariableQuality || false; if (!disableVariableQuality) { validateVariableQuality(disableVariableQuality); } + if (options.variableQualities) { + validateVariableQualities(options.variableQualities); + } + + const qualities = { ...DPR_QUALITIES, ...options.variableQualities }; + const withQuality = (path, params, dpr) => { return `${this.buildURL(path, { ...params, dpr: dpr, - q: params.q || DPR_QUALITIES[dpr], + q: params.q || qualities[dpr] || qualities[Math.floor(dpr)], })} ${dpr}x`; }; diff --git a/src/validators.js b/src/validators.js index 1b72a1d9..8d9e7480 100644 --- a/src/validators.js +++ b/src/validators.js @@ -71,3 +71,27 @@ export function validateVariableQuality(disableVariableQuality) { ); } } + +export function validateDevicePixelRatios(devicePixelRatios) { + if (!Array.isArray(devicePixelRatios) || !devicePixelRatios.length) { + throw new Error( + 'The devicePixelRatios argument can only be passed a valid non-empty array of integers', + ); + } else { + const allValidDPR = devicePixelRatios.every(function (dpr) { + return typeof dpr === 'number' && dpr >= 1 && dpr <= 5; + }); + + if (!allValidDPR) { + throw new Error( + 'The devicePixelRatios argument can only contain positive integer values between 1 and 5', + ); + } + } +} + +export function validateVariableQualities(variableQualities) { + if (typeof variableQualities !== 'object') { + throw new Error('The variableQualities argument can only be an object'); + } +} diff --git a/test/test-buildSrcSet.js b/test/test-buildSrcSet.js index feaac364..10fa866a 100644 --- a/test/test-buildSrcSet.js +++ b/test/test-buildSrcSet.js @@ -107,6 +107,14 @@ function assertDoesNotIncludeQuality(srcset) { }); } +function assertCorrectDevicePixelRatiosDescriptors(srcset, targetRatios) { + const srcsetSplit = srcset.split(','); + srcsetSplit.map((u, i) => { + const drp = parseFloat(u.split(' ')[1].slice(0, -1)); + assert.strictEqual(drp, targetRatios[i]); + }); +} + const RESOLUTIONS = [ 100, 116, @@ -763,6 +771,178 @@ describe('SrcSet Builder:', function describeSuite() { }); }); + describe('with a devicePixelRatios provided', function describeSuite() { + const CUSTOM_TARGETS_RATIOS = [1, 1.5, 2]; + + const srcset = new ImgixClient({ + domain: 'testing.imgix.net', + includeLibraryParam: false, + }).buildSrcSet( + 'image.jpg', + { w: 100 }, + { devicePixelRatios: CUSTOM_TARGETS_RATIOS }, + ); + + it('should return the expected number of `url targetRatios` pairs', function testSpec() { + assert.strictEqual(srcset.split(',').length, 3); + }); + + it('should generate the srcset with excepted targets ratios', function testSpec() { + assertCorrectDevicePixelRatiosDescriptors( + srcset, + CUSTOM_TARGETS_RATIOS, + ); + }); + + it('errors with non-array argument', function testSpec() { + assert.throws(function () { + new ImgixClient({ + domain: 'testing.imgix.net', + }).buildSrcSet( + 'image.jpg', + { w: 100 }, + { devicePixelRatios: 'abc' }, + ); + }, Error); + }); + + it('errors with empty array argument', function testSpec() { + assert.throws(function () { + new ImgixClient({ + domain: 'testing.imgix.net', + }).buildSrcSet('image.jpg', { w: 100 }, { devicePixelRatios: [] }); + }, Error); + }); + + it('errors with invalid ratios', function testSpec() { + assert.throws(function () { + new ImgixClient({ + domain: 'testing.imgix.net', + }).buildSrcSet( + 'image.jpg', + { w: 100 }, + { devicePixelRatios: [1, 10] }, + ); + }, Error); + + assert.throws(function () { + new ImgixClient({ + domain: 'testing.imgix.net', + }).buildSrcSet( + 'image.jpg', + { w: 100 }, + { devicePixelRatios: ['a', 'b', 'c'] }, + ); + }, Error); + }); + }); + + describe('with a variableQualities provided', function describeSuite() { + const DPR_QUALITY = [75, 50, 35, 23, 20]; + + const CUSTOM_TARGETS_RATIOS_QUALITIES = { + 1: 45, + 2: 30, + 3: 20, + 4: 15, + 5: 10, + }; + + const srcset = new ImgixClient({ + domain: 'testing.imgix.net', + includeLibraryParam: false, + }).buildSrcSet( + 'image.jpg', + { w: 100 }, + { variableQualities: CUSTOM_TARGETS_RATIOS_QUALITIES }, + ); + + it('should return the expected qualities', function testSpec() { + assertIncludesQualities( + srcset, + Object.values(CUSTOM_TARGETS_RATIOS_QUALITIES), + ); + }); + + it('should return the expected qualities with default merge', function testSpec() { + const srcset = new ImgixClient({ + domain: 'testing.imgix.net', + includeLibraryParam: false, + }).buildSrcSet( + 'image.jpg', + { w: 100 }, + { variableQualities: { 1: 40, 2: 35 } }, + ); + + assertIncludesQualities(srcset, [ + 40, + 35, + DPR_QUALITY[2], + DPR_QUALITY[3], + DPR_QUALITY[4], + ]); + }); + + it('should override the variable quality if a quality parameter is provided', function testSpec() { + const QUALITY_OVERRIDE = 100; + + const srcset = new ImgixClient({ + domain: 'testing.imgix.net', + }).buildSrcSet( + 'image.jpg', + { w: 800, q: QUALITY_OVERRIDE }, + { variableQualities: CUSTOM_TARGETS_RATIOS_QUALITIES }, + ); + + assertIncludesQualityOverride(srcset, QUALITY_OVERRIDE); + }); + + it('should respect a provided quality parameter when variable qualities are disabled', function testSpec() { + const QUALITY_OVERRIDE = 100; + const srcset = new ImgixClient({ + domain: 'testing.imgix.net', + }).buildSrcSet( + 'image.jpg', + { w: 800, q: QUALITY_OVERRIDE }, + { + disableVariableQuality: true, + variableQualities: CUSTOM_TARGETS_RATIOS_QUALITIES, + }, + ); + + assertIncludesQualityOverride(srcset, QUALITY_OVERRIDE); + }); + + it('should work with float dpr', function testSpec() { + const srcset = new ImgixClient({ + domain: 'testing.imgix.net', + }).buildSrcSet( + 'image.jpg', + { w: 800 }, + { + devicePixelRatios: [1, 1.5, 2], + variableQualities: { + 1.5: 60, + }, + }, + ); + + assertIncludesQualities(srcset, [DPR_QUALITY[0], 60, DPR_QUALITY[1]]); + }); + + it('errors with non-object argument', function testSpec() { + assert.throws(function () { + new ImgixClient({ + domain: 'testing.imgix.net', + }).buildSrcSet( + 'image.jpg', + { w: 100 }, + { variableQualities: 'abc' }, + ); + }, Error); + }); + }); + describe('ImgixClient.targetWidths', function describeSuite() { it('produces the default target width resolutions given default args', function testSpec() { const actual = ImgixClient.targetWidths(); diff --git a/types/imgix-js-core-test.ts b/types/imgix-js-core-test.ts index 6393474d..649f6568 100644 --- a/types/imgix-js-core-test.ts +++ b/types/imgix-js-core-test.ts @@ -1,4 +1,4 @@ -import ImgixClient from 'index'; +import ImgixClient, { SrcSetOptions } from 'index'; const expectedToken = 'MYT0KEN'; // $ExpectType ImgixClient @@ -33,12 +33,17 @@ client._buildParams(params); // $ExpectType string client._signParams(path, params); -const options = { +const options: SrcSetOptions = { widths: [100, 500, 1000], widthTolerance: 0.05, minWidth: 500, maxWidth: 2000, disableVariableQuality: false, + devicePixelRatios: [1, 2], + variableQualities: { + 1: 45, + 2: 30, + }, }; // $ExpectType string diff --git a/types/index.d.ts b/types/index.d.ts index 123a19e5..91365eae 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -26,12 +26,18 @@ declare class ImgixClient { ): number[]; } +export type DevicePixelRatio = 1 | 2 | 3 | 4 | 5 | number; + +export type VariableQualities = { [key in DevicePixelRatio]?: number }; + export interface SrcSetOptions { widths?: number[]; widthTolerance?: number; minWidth?: number; maxWidth?: number; disableVariableQuality?: boolean; + devicePixelRatios?: DevicePixelRatio[]; + variableQualities?: VariableQualities; } export default ImgixClient;