From 4e952ba216297eb60fb3bae797b73f5b72c7660b Mon Sep 17 00:00:00 2001 From: Katie Hempenius Date: Tue, 7 Jun 2022 00:53:19 +0000 Subject: [PATCH] feat(common): add loaders for cloudinary & imagekit (#47082) This commit adds loaders for cloudinary and imagekit. PR Close #47082 --- .../image_loaders/cloudinary_loader.ts | 54 ++++++ .../image_loaders/imagekit_loader.ts | 53 ++++++ .../image_loaders/imgix_loader.ts | 27 +-- .../image_loaders/loader_utils.ts | 22 +++ .../test/image_loaders/image_loader_spec.ts | 167 +++++++++++++----- 5 files changed, 257 insertions(+), 66 deletions(-) create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/loader_utils.ts diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts new file mode 100644 index 0000000000000..63cd2d7a5345f --- /dev/null +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Provider, ɵRuntimeError as RuntimeError} from '@angular/core'; + +import {RuntimeErrorCode} from '../../../errors'; + +import {IMAGE_LOADER, ImageLoaderConfig} from './image_loader'; +import {isValidPath, normalizePath, normalizeSrc} from './loader_utils'; + +/** + * Function that generates a built-in ImageLoader for Cloudinary + * and turns it into an Angular provider. + * + * @param path Base URL of your Cloudinary images + * This URL should match one of the following formats: + * https://res.cloudinary.com/mysite + * https://mysite.cloudinary.com + * https://subdomain.mysite.com + * @returns Provider that provides an ImageLoader function + */ +export function provideCloudinaryLoader(path: string): Provider { + if (ngDevMode && !isValidPath(path)) { + throwInvalidPathError(path); + } + path = normalizePath(path); + + return { + provide: IMAGE_LOADER, + useValue: (config: ImageLoaderConfig) => { + // Example of a Cloudinary image URL: + // https://res.cloudinary.com/mysite/image/upload/c_scale,f_auto,q_auto,w_600/marketing/tile-topics-m.png + let params = `f_auto,q_auto`; // sets image format and quality to "auto" + if (config.width) { + params += `,w_${config.width}`; + } + const url = `${path}/image/upload/${params}/${normalizeSrc(config.src)}`; + return url; + } + }; +} + +function throwInvalidPathError(path: unknown): never { + throw new RuntimeError( + RuntimeErrorCode.INVALID_INPUT, + `CloudinaryLoader has detected an invalid path: ` + + `expecting a path matching one of the following formats: https://res.cloudinary.com/mysite, https://mysite.cloudinary.com, or https://subdomain.mysite.com - ` + + `but got: \`${path}\``); +} diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts new file mode 100644 index 0000000000000..67e8c65a743b6 --- /dev/null +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Provider, ɵRuntimeError as RuntimeError} from '@angular/core'; + +import {RuntimeErrorCode} from '../../../errors'; + +import {IMAGE_LOADER, ImageLoaderConfig} from './image_loader'; +import {isValidPath, normalizePath, normalizeSrc} from './loader_utils'; + +/** + * Function that generates a built-in ImageLoader for ImageKit + * and turns it into an Angular provider. + * + * @param path Base URL of your ImageKit images + * This URL should match one of the following formats: + * https://ik.imagekit.io/myaccount + * https://subdomain.mysite.com + * @returns Provider that provides an ImageLoader function + */ +export function provideImageKitLoader(path: string): Provider { + if (ngDevMode && !isValidPath(path)) { + throwInvalidPathError(path); + } + path = normalizePath(path); + + return { + provide: IMAGE_LOADER, + useValue: (config: ImageLoaderConfig) => { + // Example of an ImageKit image URL: + // https://ik.imagekit.io/demo/tr:w-300,h-300/medium_cafe_B1iTdD0C.jpg + let params = `tr:q-auto`; // applies the "auto quality" transformation + if (config.width) { + params += `,w-${config.width?.toString()}`; + }; + const url = `${path}/${params}/${normalizeSrc(config.src)}`; + return url; + } + }; +} + +function throwInvalidPathError(path: unknown): never { + throw new RuntimeError( + RuntimeErrorCode.INVALID_INPUT, + `ImageKitLoader has detected an invalid path: ` + + `expecting a path matching one of the following formats: https://ik.imagekit.io/mysite or https://subdomain.mysite.com - ` + + `but got: \`${path}\``); +} diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts index 48148cde11ae1..a87806dd58621 100644 --- a/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts @@ -12,6 +12,7 @@ import {RuntimeErrorCode} from '../../../errors'; import {PRECONNECT_CHECK_BLOCKLIST} from '../preconnect_link_checker'; import {IMAGE_LOADER, ImageLoaderConfig} from './image_loader'; +import {isValidPath, normalizePath, normalizeSrc} from './loader_utils'; /** * Function that generates a built-in ImageLoader for Imgix and turns it @@ -24,7 +25,9 @@ import {IMAGE_LOADER, ImageLoaderConfig} from './image_loader'; export function provideImgixLoader(path: string, options: {ensurePreconnect?: boolean} = { ensurePreconnect: true }) { - ngDevMode && assertValidPath(path); + if (ngDevMode && !isValidPath(path)) { + throwInvalidPathError(path); + } path = normalizePath(path); const providers: Provider[] = [{ @@ -45,20 +48,6 @@ export function provideImgixLoader(path: string, options: {ensurePreconnect?: bo return providers; } -function assertValidPath(path: unknown) { - const isString = typeof path === 'string'; - - if (!isString || path.trim() === '') { - throwInvalidPathError(path); - } - - try { - const url = new URL(path); - } catch { - throwInvalidPathError(path); - } -} - function throwInvalidPathError(path: unknown): never { throw new RuntimeError( RuntimeErrorCode.INVALID_INPUT, @@ -66,11 +55,3 @@ function throwInvalidPathError(path: unknown): never { `expecting a path like https://somepath.imgix.net/` + `but got: \`${path}\``); } - -function normalizePath(path: string) { - return path[path.length - 1] === '/' ? path.slice(0, -1) : path; -} - -function normalizeSrc(src: string) { - return src[0] === '/' ? src.slice(1) : src; -} diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/loader_utils.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/loader_utils.ts new file mode 100644 index 0000000000000..070b6f39a124a --- /dev/null +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/loader_utils.ts @@ -0,0 +1,22 @@ +export function isValidPath(path: unknown) { + const isString = typeof path === 'string'; + + if (!isString || path.trim() === '') { + return false; + } + + try { + const url = new URL(path); + return true; + } catch { + return false; + } +} + +export function normalizePath(path: string) { + return path.endsWith('/') ? path.slice(0, -1) : path; +} + +export function normalizeSrc(src: string) { + return src.startsWith('/') ? src.slice(1) : src; +} \ No newline at end of file diff --git a/packages/common/test/image_loaders/image_loader_spec.ts b/packages/common/test/image_loaders/image_loader_spec.ts index 4d6f5c1467986..341811a31c51f 100644 --- a/packages/common/test/image_loaders/image_loader_spec.ts +++ b/packages/common/test/image_loaders/image_loader_spec.ts @@ -7,8 +7,10 @@ */ import {IMAGE_LOADER, ImageLoader, PRECONNECT_CHECK_BLOCKLIST} from '@angular/common/src/directives/ng_optimized_image'; +import {provideCloudinaryLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader'; +import {provideImageKitLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader'; import {provideImgixLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imgix_loader'; -import {RuntimeErrorCode} from '@angular/common/src/errors'; +import {isValidPath} from '@angular/common/src/directives/ng_optimized_image/image_loaders/loader_utils'; import {createEnvironmentInjector, ValueProvider} from '@angular/core'; describe('Built-in image directive loaders', () => { @@ -18,78 +20,55 @@ describe('Built-in image directive loaders', () => { return injector.get(IMAGE_LOADER); } - function invalidPathError(path: string): string { - return `NG0${RuntimeErrorCode.INVALID_INPUT}: ImgixLoader has detected ` + - `an invalid path: expecting a path like https://somepath.imgix.net/` + - `but got: \`${path}\``; - } - - describe('invalid paths', () => { - it('should throw when a path is empty', () => { - const path = ''; - expect(() => provideImgixLoader(path)).toThrowError(invalidPathError(path)); - }); - - it('should throw when a path is not a URL', () => { - const path = 'wellhellothere'; - expect(() => provideImgixLoader(path)).toThrowError(invalidPathError(path)); - }); - - it('should throw when a path is missing a scheme', () => { - const path = 'somepath.imgix.net'; - expect(() => provideImgixLoader(path)).toThrowError(invalidPathError(path)); - }); - - it('should throw when a path is malformed', () => { - const path = 'somepa\th.imgix.net? few'; - expect(() => provideImgixLoader(path)).toThrowError(invalidPathError(path)); - }); - }); - it('should construct an image loader with the given path', () => { - const loader = createImgixLoader('https://somesite.imgix.net'); + const path = 'https://somesite.imgix.net'; + const loader = createImgixLoader(path); const config = {src: 'img.png'}; - expect(loader(config)).toBe('https://somesite.imgix.net/img.png?auto=format'); + expect(loader(config)).toBe(`${path}/img.png?auto=format`); }); it('should handle a trailing forward slash on the path', () => { - const loader = createImgixLoader('https://somesite.imgix.net/'); + const path = 'https://somesite.imgix.net'; + const loader = createImgixLoader(`${path}/`); const config = {src: 'img.png'}; - expect(loader(config)).toBe('https://somesite.imgix.net/img.png?auto=format'); + expect(loader(config)).toBe(`${path}/img.png?auto=format`); }); - it('should handle a leading forward slash on the src', () => { - const loader = createImgixLoader('https://somesite.imgix.net'); + it('should handle a leading forward slash on the image src', () => { + const path = 'https://somesite.imgix.net'; + const loader = createImgixLoader(path); const config = {src: '/img.png'}; - expect(loader(config)).toBe('https://somesite.imgix.net/img.png?auto=format'); + expect(loader(config)).toBe(`${path}/img.png?auto=format`); }); it('should construct an image loader with the given path', () => { - const loader = createImgixLoader('https://somesite.imgix.net'); + const path = 'https://somesite.imgix.net'; + const loader = createImgixLoader(path); const config = {src: 'img.png', width: 100}; - expect(loader(config)).toBe('https://somesite.imgix.net/img.png?auto=format&w=100'); + expect(loader(config)).toBe(`${path}/img.png?auto=format&w=100`); }); describe('options', () => { it('should configure PRECONNECT_CHECK_BLOCKLIST token by default', () => { - const providers = provideImgixLoader('https://somesite.imgix.net'); + const path = 'https://somesite.imgix.net'; + const providers = provideImgixLoader(path); expect(providers.length).toBe(2); const valueProvider = providers[1] as ValueProvider; expect(valueProvider.multi).toBeTrue(); - expect(valueProvider.useValue).toEqual(['https://somesite.imgix.net']); + expect(valueProvider.useValue).toEqual([path]); expect(valueProvider.provide).toBe(PRECONNECT_CHECK_BLOCKLIST); }); it('should configure PRECONNECT_CHECK_BLOCKLIST when the ensurePreconnect was specified', () => { - const providers = - provideImgixLoader('https://somesite.imgix.net', {ensurePreconnect: true}); + const path = 'https://somesite.imgix.net'; + const providers = provideImgixLoader(path, {ensurePreconnect: true}); expect(providers.length).toBe(2); const valueProvider = providers[1] as ValueProvider; expect(valueProvider.multi).toBeTrue(); - expect(valueProvider.useValue).toEqual(['https://somesite.imgix.net']); + expect(valueProvider.useValue).toEqual([path]); expect(valueProvider.provide).toBe(PRECONNECT_CHECK_BLOCKLIST); }); @@ -101,4 +80,106 @@ describe('Built-in image directive loaders', () => { }); }); }); + + describe('Cloudinary loader', () => { + function createCloudinaryLoader(path: string): ImageLoader { + const injector = createEnvironmentInjector([provideCloudinaryLoader(path)]); + return injector.get(IMAGE_LOADER); + } + + it('should construct an image loader with the given path', () => { + const path = 'https://somesite.imgix.net/mysite'; + const loader = createCloudinaryLoader(path); + expect(loader({src: 'img.png'})).toBe(`${path}/image/upload/f_auto,q_auto/img.png`); + expect(loader({ + src: 'marketing/img-2.png' + })).toBe(`${path}/image/upload/f_auto,q_auto/marketing/img-2.png`); + }); + + describe('input validation', () => { + it('should throw if the path is invalid', () => { + expect(() => provideCloudinaryLoader('my-cloudinary-account')) + .toThrowError( + `NG02952: CloudinaryLoader has detected an invalid path: ` + + `expecting a path matching one of the following formats: ` + + `https://res.cloudinary.com/mysite, https://mysite.cloudinary.com, ` + + `or https://subdomain.mysite.com - but got: \`my-cloudinary-account\``); + }); + + it('should handle a trailing forward slash on the path', () => { + const path = 'https://somesite.imgix.net/mysite'; + const loader = createCloudinaryLoader(`${path}/`); + expect(loader({src: 'img.png'})).toBe(`${path}/image/upload/f_auto,q_auto/img.png`); + }); + + it('should handle a leading forward slash on the image src', () => { + const path = 'https://somesite.imgix.net/mysite'; + const loader = createCloudinaryLoader(path); + expect(loader({src: '/img.png'})).toBe(`${path}/image/upload/f_auto,q_auto/img.png`); + }); + }); + }); + + describe('ImageKit loader', () => { + function createImageKitLoader(path: string): ImageLoader { + const injector = createEnvironmentInjector([provideImageKitLoader(path)]); + return injector.get(IMAGE_LOADER); + } + + it('should construct an image loader with the given path', () => { + const path = 'https://ik.imageengine.io/imagetest'; + const loader = createImageKitLoader(path); + expect(loader({src: 'img.png'})).toBe(`${path}/tr:q-auto/img.png`); + expect(loader({src: 'marketing/img-2.png'})).toBe(`${path}/tr:q-auto/marketing/img-2.png`); + }); + + describe('input validation', () => { + it('should throw if the path is invalid', () => { + expect(() => provideImageKitLoader('my-imagekit-account')) + .toThrowError( + `NG02952: ImageKitLoader has detected an invalid path: ` + + `expecting a path matching one of the following formats: ` + + `https://ik.imagekit.io/mysite or https://subdomain.mysite.com - ` + + `but got: \`my-imagekit-account\``); + }); + + it('should handle a trailing forward slash on the path', () => { + const path = 'https://ik.imageengine.io/imagetest'; + const loader = createImageKitLoader(`${path}/`); + expect(loader({src: 'img.png'})).toBe(`${path}/tr:q-auto/img.png`); + }); + + it('should handle a leading forward slash on the image src', () => { + const path = 'https://ik.imageengine.io/imagetest'; + const loader = createImageKitLoader(path); + expect(loader({src: '/img.png'})).toBe(`${path}/tr:q-auto/img.png`); + }); + }); + }); + + describe('loader utils', () => { + describe('path validation', () => { + it('should identify valid paths', () => { + expect(isValidPath('https://cdn.imageprovider.com/image-test')).toBe(true); + expect(isValidPath('https://cdn.imageprovider.com')).toBe(true); + expect(isValidPath('https://imageprovider.com')).toBe(true); + }); + + it('should reject empty paths', () => { + expect(isValidPath('')).toBe(false); + }); + + it('should reject path if it is not a URL', () => { + expect(isValidPath('myaccount')).toBe(false); + }); + + it('should reject path if it does not include a protocol', () => { + expect(isValidPath('myaccount.imageprovider.com')).toBe(false); + }); + + it('should reject path if is malformed', () => { + expect(isValidPath('somepa\th.imageprovider.com? few')).toBe(false); + }); + }); + }); });