From 2b4984aaccdaba564cd57f875067626482c7660b Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Mon, 31 Jan 2022 14:11:01 -1000 Subject: [PATCH] feat: add `asImageSrc()`, `asImageWidthSrcSet()`, `asImagePixelDensitySrcSet()` (#38) * feat: add `asImageSrc`, `asImageWidthSrcSet`, `asImagePixelDensitiesSrcSet` * refactor: rename `asImagePixelDensitiesSrcSet` file * test: image helpers * chore(deps): update `imgix-url-builder` * feat: add responsive-view-based image srcset builder * feat: add default pixel densities * refactor: `asImageWidthSrcSet()` * feat: return src and srcset from srcset helpers * refactor: image helpers * docs: update examples * chore(deps): update dependencies * test: remove snapshots * test: add snapshots * chore: force ava to run in non-ci mode * Revert "chore: force ava to run in non-ci mode" This reverts commit a37fe3939240f6301116b3f472cfe4704887b9a1. * chore: lock AVA to v4.0.0 * Revert "chore: lock AVA to v4.0.0" This reverts commit b0612e08663924c2f08cfb610075482d47e94fb5. * chore: remove nyc from `unit` script * Revert "chore: remove nyc from `unit` script" This reverts commit a4c54fe661bbf378293de9fb3a695fa04239f951. * chore: move snapshots adjacent to test files * chore: revert to AVA 3 * fix: use Next.js's default srcset widths * docs: reformat `pixelDensities` default value description * test: use non-default pixelDensities in test * docs: update `asImagePixelDensitySrcSet()` example with custom pixel densities * chore: fix package-lock.json * fix: apply width to base srcset image in `asImageWidthSrcSet()` with responsive views * fix: reduce number of default widths in `asImageWidthSrcSet()` --- package-lock.json | 16 +++- package.json | 3 +- src/asImagePixelDensitySrcSet.ts | 77 ++++++++++++++++++ src/asImageSrc.ts | 40 ++++++++++ src/asImageWidthSrcSet.ts | 106 +++++++++++++++++++++++++ src/index.ts | 3 + test/asImagePixelDensitySrcSet.test.ts | 71 +++++++++++++++++ test/asImageSrc.test.ts | 32 ++++++++ test/asImageWidthSrcSet.test.ts | 104 ++++++++++++++++++++++++ 9 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 src/asImagePixelDensitySrcSet.ts create mode 100644 src/asImageSrc.ts create mode 100644 src/asImageWidthSrcSet.ts create mode 100644 test/asImagePixelDensitySrcSet.test.ts create mode 100644 test/asImageSrc.test.ts create mode 100644 test/asImageWidthSrcSet.test.ts diff --git a/package-lock.json b/package-lock.json index d554d1a..498abb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@prismicio/richtext": "^2.0.1", "@prismicio/types": "^0.1.22", - "escape-html": "^1.0.3" + "escape-html": "^1.0.3", + "imgix-url-builder": "^0.0.2" }, "devDependencies": { "@prismicio/mock": "^0.0.6", @@ -4432,6 +4433,14 @@ "node": ">=10 <11 || >=12 <13 || >=14" } }, + "node_modules/imgix-url-builder": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/imgix-url-builder/-/imgix-url-builder-0.0.2.tgz", + "integrity": "sha512-PHT9aXvD+I6x5UAdvsAKNALvxHI1AWGGpxLUQDQAXzUt54ScFHxxODJ/4/XWDM55cqvRCvT3MMxbOWjgWhLe9Q==", + "engines": { + "node": ">=12.7.0" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -11594,6 +11603,11 @@ "integrity": "sha512-+mQSgMRiFD3L3AOxLYOCxjIq4OnAmo5CIuC+lj5ehCJcPtV++QacEV7FdpzvYxH6DaOySWzQU6RR0lPLy37ckA==", "dev": true }, + "imgix-url-builder": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/imgix-url-builder/-/imgix-url-builder-0.0.2.tgz", + "integrity": "sha512-PHT9aXvD+I6x5UAdvsAKNALvxHI1AWGGpxLUQDQAXzUt54ScFHxxODJ/4/XWDM55cqvRCvT3MMxbOWjgWhLe9Q==" + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", diff --git a/package.json b/package.json index 9f3f2fd..ff03dff 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "dependencies": { "@prismicio/richtext": "^2.0.1", "@prismicio/types": "^0.1.22", - "escape-html": "^1.0.3" + "escape-html": "^1.0.3", + "imgix-url-builder": "^0.0.2" }, "devDependencies": { "@prismicio/mock": "^0.0.6", diff --git a/src/asImagePixelDensitySrcSet.ts b/src/asImagePixelDensitySrcSet.ts new file mode 100644 index 0000000..598bdb5 --- /dev/null +++ b/src/asImagePixelDensitySrcSet.ts @@ -0,0 +1,77 @@ +import { ImageFieldImage } from "@prismicio/types"; +import { + buildPixelDensitySrcSet, + BuildPixelDensitySrcSetParams, + buildURL, +} from "imgix-url-builder"; + +import { imageThumbnail as isImageThumbnailFilled } from "./isFilled"; + +/** + * The return type of `asImagePixelDensitySrcSet()`. + */ +type AsImagePixelDensitySrcSetReturnType = + Field extends ImageFieldImage<"empty"> + ? null + : { + /** + * The Image field's image URL with Imgix URL parameters (if given). + */ + src: string; + + /** + * A pixel-densitye-based `srcset` attribute value for the Image field's + * image with Imgix URL parameters (if given). + */ + srcset: string; + }; + +/** + * Creates a pixel-density-based `srcset` from an Image field with optional + * image transformations (via Imgix URL parameters). + * + * If a `pixelDensities` parameter is not given, the following pixel densities + * will be used by default: 1, 2, 3. + * + * @example + * + * ```ts + * const srcset = asImagePixelDensitySrcSet(document.data.imageField, { + * pixelDensities: [1, 2], + * sat: -100, + * }); + * // => { + * // src: 'https://images.prismic.io/repo/image.png?sat=-100', + * // srcset: 'https://images.prismic.io/repo/image.png?sat=-100&dpr=1 1x, ' + + * // 'https://images.prismic.io/repo/image.png?sat=-100&dpr=2 2x' + * // } + * ``` + * + * @param field - Image field (or one of its responsive views) from which to get + * an image URL. + * @param params - An object of Imgix URL API parameters. The `pixelDensities` + * parameter defines the resulting `srcset` widths. + * + * @returns A `srcset` attribute value for the Image field with Imgix URL + * parameters (if given). If the Image field is empty, `null` is returned. + * @see Imgix URL parameters reference: https://docs.imgix.com/apis/rendering + */ +export const asImagePixelDensitySrcSet = ( + field: Field, + params: Omit & + Partial> = {}, +): AsImagePixelDensitySrcSetReturnType => { + if (isImageThumbnailFilled(field)) { + const { pixelDensities = [1, 2, 3], ...imgixParams } = params; + + return { + src: buildURL(field.url, imgixParams), + srcset: buildPixelDensitySrcSet(field.url, { + ...imgixParams, + pixelDensities, + }), + } as AsImagePixelDensitySrcSetReturnType; + } else { + return null as AsImagePixelDensitySrcSetReturnType; + } +}; diff --git a/src/asImageSrc.ts b/src/asImageSrc.ts new file mode 100644 index 0000000..83f8d44 --- /dev/null +++ b/src/asImageSrc.ts @@ -0,0 +1,40 @@ +import { ImageFieldImage } from "@prismicio/types"; +import { buildURL, ImgixURLParams } from "imgix-url-builder"; + +import { imageThumbnail as isImageThumbnailFilled } from "./isFilled"; + +/** + * The return type of `asImageSrc()`. + */ +type AsImageSrcReturnType = + Field extends ImageFieldImage<"empty"> ? null : string; + +/** + * Returns the URL of an Image field with optional image transformations (via + * Imgix URL parameters). + * + * @example + * + * ```ts + * const src = asImageSrc(document.data.imageField, { sat: -100 }); + * // => https://images.prismic.io/repo/image.png?sat=-100 + * ``` + * + * @param field - Image field (or one of its responsive views) from which to get + * an image URL. + * @param params - An object of Imgix URL API parameters to transform the image. + * + * @returns The Image field's image URL with transformations applied (if given). + * If the Image field is empty, `null` is returned. + * @see Imgix URL parameters reference: https://docs.imgix.com/apis/rendering + */ +export const asImageSrc = ( + field: Field, + params: ImgixURLParams = {}, +): AsImageSrcReturnType => { + if (isImageThumbnailFilled(field)) { + return buildURL(field.url, params) as AsImageSrcReturnType; + } else { + return null as AsImageSrcReturnType; + } +}; diff --git a/src/asImageWidthSrcSet.ts b/src/asImageWidthSrcSet.ts new file mode 100644 index 0000000..b8aa90c --- /dev/null +++ b/src/asImageWidthSrcSet.ts @@ -0,0 +1,106 @@ +import { ImageFieldImage } from "@prismicio/types"; +import { + buildURL, + buildWidthSrcSet, + BuildWidthSrcSetParams, +} from "imgix-url-builder"; + +import { imageThumbnail as isImageThumbnailFilled } from "./isFilled"; + +/** + * The return type of `asImageWidthSrcSet()`. + */ +type AsImageWidthSrcSetReturnType = + Field extends ImageFieldImage<"empty"> + ? null + : { + /** + * The Image field's image URL with Imgix URL parameters (if given). + */ + src: string; + + /** + * A width-based `srcset` attribute value for the Image field's image + * with Imgix URL parameters (if given). + */ + srcset: string; + }; + +/** + * Creates a width-based `srcset` from an Image field with optional image + * transformations (via Imgix URL parameters). + * + * If the Image field contains responsive views, each responsive view is used as + * a width in the resulting `srcset`. + * + * If a `widths` parameter is not given, the following widths will be used by + * default: 640, 750, 828, 1080, 1200, 1920, 2048, 3840. + * + * @example + * + * ```ts + * const srcset = asImageWidthSrcSet(document.data.imageField, { + * widths: [400, 800, 1600], + * sat: -100, + * }); + * // => { + * // src: 'https://images.prismic.io/repo/image.png?sat=-100', + * // srcset: 'https://images.prismic.io/repo/image.png?sat=-100&width=400 400w, ' + + * // 'https://images.prismic.io/repo/image.png?sat=-100&width=800 800w,' + + * // 'https://images.prismic.io/repo/image.png?sat=-100&width=1600 1600w' + * // } + * ``` + * + * @param field - Image field (or one of its responsive views) from which to get + * an image URL. + * @param params - An object of Imgix URL API parameters. The `widths` parameter + * defines the resulting `srcset` widths. + * + * @returns A `srcset` attribute value for the Image field with Imgix URL + * parameters (if given). If the Image field is empty, `null` is returned. + * @see Imgix URL parameters reference: https://docs.imgix.com/apis/rendering + */ +export const asImageWidthSrcSet = ( + field: Field, + params: Omit & + Partial> = {}, +): AsImageWidthSrcSetReturnType => { + if (isImageThumbnailFilled(field)) { + const { widths = [640, 828, 1200, 2048, 3840], ...urlParams } = params; + const { + url, + dimensions, + alt: _alt, + copyright: _copyright, + ...responsiveViews + } = field; + + // The Prismic Rest API will always return thumbnail values if + // the base size is filled. + const responsiveViewObjects: ImageFieldImage<"filled">[] = + Object.values(responsiveViews); + + return { + src: buildURL(url, urlParams), + srcset: responsiveViewObjects.length + ? [ + buildWidthSrcSet(url, { + ...urlParams, + widths: [dimensions.width], + }), + ...responsiveViewObjects.map((thumbnail) => { + return buildWidthSrcSet(thumbnail.url, { + ...urlParams, + widths: [thumbnail.dimensions.width], + }); + }), + ].join(", ") + : buildWidthSrcSet(field.url, { + ...urlParams, + widths, + }), + } as AsImageWidthSrcSetReturnType; + } else { + return null as AsImageWidthSrcSetReturnType; + } +}; diff --git a/src/index.ts b/src/index.ts index 54ac201..3674af3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,9 @@ export { asDate } from "./asDate"; export { asLink } from "./asLink"; export { asText } from "./asText"; export { asHTML } from "./asHTML"; +export { asImageSrc } from "./asImageSrc"; +export { asImageWidthSrcSet } from "./asImageWidthSrcSet"; +export { asImagePixelDensitySrcSet } from "./asImagePixelDensitySrcSet"; export * as isFilled from "./isFilled"; export { documentToLinkField } from "./documentToLinkField"; diff --git a/test/asImagePixelDensitySrcSet.test.ts b/test/asImagePixelDensitySrcSet.test.ts new file mode 100644 index 0000000..5c50863 --- /dev/null +++ b/test/asImagePixelDensitySrcSet.test.ts @@ -0,0 +1,71 @@ +import { ImageField } from "@prismicio/types"; +import test from "ava"; + +import { asImagePixelDensitySrcSet } from "../src"; + +test("returns an image field pixel-density-based srcset with [1, 2, 3] pxiel densities by default", (t) => { + const field: ImageField = { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 400, height: 300 }, + }; + + t.deepEqual(asImagePixelDensitySrcSet(field), { + src: field.url, + srcset: + `${field.url}&dpr=1 1x, ` + + `${field.url}&dpr=2 2x, ` + + `${field.url}&dpr=3 3x`, + }); +}); + +test("supports custom pixel densities", (t) => { + const field: ImageField = { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 400, height: 300 }, + }; + + t.deepEqual( + asImagePixelDensitySrcSet(field, { + pixelDensities: [2, 4, 6], + }), + { + src: field.url, + srcset: + `${field.url}&dpr=2 2x, ` + + `${field.url}&dpr=4 4x, ` + + `${field.url}&dpr=6 6x`, + }, + ); +}); + +test("applies given Imgix URL parameters", (t) => { + const field: ImageField = { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 400, height: 300 }, + }; + + t.deepEqual( + asImagePixelDensitySrcSet(field, { + sat: 100, + }), + { + src: `${field.url}&sat=100`, + srcset: + `${field.url}&sat=100&dpr=1 1x, ` + + `${field.url}&sat=100&dpr=2 2x, ` + + `${field.url}&sat=100&dpr=3 3x`, + }, + ); +}); + +test("returns null when image field is empty", (t) => { + const field: ImageField = {}; + + t.is(asImagePixelDensitySrcSet(field), null); +}); diff --git a/test/asImageSrc.test.ts b/test/asImageSrc.test.ts new file mode 100644 index 0000000..d9e39d3 --- /dev/null +++ b/test/asImageSrc.test.ts @@ -0,0 +1,32 @@ +import { ImageField } from "@prismicio/types"; +import test from "ava"; + +import { asImageSrc } from "../src"; + +test("returns an image field URL", (t) => { + const field: ImageField = { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 400, height: 300 }, + }; + + t.is(asImageSrc(field), field.url); +}); + +test("applies given Imgix URL parameters", (t) => { + const field: ImageField = { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 400, height: 300 }, + }; + + t.is(asImageSrc(field, { sat: 100 }), `${field.url}&sat=100`); +}); + +test("returns null when image field is empty", (t) => { + const field: ImageField = {}; + + t.is(asImageSrc(field), null); +}); diff --git a/test/asImageWidthSrcSet.test.ts b/test/asImageWidthSrcSet.test.ts new file mode 100644 index 0000000..c66b8ec --- /dev/null +++ b/test/asImageWidthSrcSet.test.ts @@ -0,0 +1,104 @@ +import { ImageField } from "@prismicio/types"; +import test from "ava"; + +import { asImageWidthSrcSet } from "../src"; + +test("returns an image field src and width-based srcset with [640, 750, 828, 1080, 1200, 1920, 2048, 3840] widths by default", (t) => { + const field: ImageField = { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 400, height: 300 }, + }; + + t.deepEqual(asImageWidthSrcSet(field), { + src: field.url, + srcset: + `${field.url}&width=640 640w, ` + + `${field.url}&width=828 828w, ` + + `${field.url}&width=1200 1200w, ` + + `${field.url}&width=2048 2048w, ` + + `${field.url}&width=3840 3840w`, + }); +}); + +test("supports custom widths", (t) => { + const field: ImageField = { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 400, height: 300 }, + }; + + t.deepEqual( + asImageWidthSrcSet(field, { + widths: [400, 800, 1600], + }), + { + src: field.url, + srcset: + `${field.url}&width=400 400w, ` + + `${field.url}&width=800 800w, ` + + `${field.url}&width=1600 1600w`, + }, + ); +}); + +test("applies given Imgix URL parameters", (t) => { + const field: ImageField = { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 400, height: 300 }, + }; + + t.deepEqual( + asImageWidthSrcSet(field, { + sat: 100, + }), + { + src: `${field.url}&sat=100`, + srcset: + `${field.url}&sat=100&width=640 640w, ` + + `${field.url}&sat=100&width=828 828w, ` + + `${field.url}&sat=100&width=1200 1200w, ` + + `${field.url}&sat=100&width=2048 2048w, ` + + `${field.url}&sat=100&width=3840 3840w`, + }, + ); +}); + +test("returns a srcset of responsive views if the field contains responsive views", (t) => { + const field = { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 1000, height: 800 }, + foo: { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 500, height: 400 }, + }, + bar: { + url: "https://images.prismic.io/qwerty/image.png?auto=compress%2Cformat", + alt: null, + copyright: null, + dimensions: { width: 250, height: 200 }, + }, + }; + + t.deepEqual(asImageWidthSrcSet(field), { + src: field.url, + srcset: + `${field.url}&width=1000 1000w, ` + + `${field.foo.url}&width=500 500w, ` + + `${field.bar.url}&width=250 250w`, + }); +}); + +test("returns null when image field is empty", (t) => { + const field: ImageField = {}; + + t.is(asImageWidthSrcSet(field), null); +});