From 7ce497e5bc6502bed8099d2592888f3164cdcf1f Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Fri, 27 May 2022 12:14:32 -0700 Subject: [PATCH] feat(common): add built-in Imgix loader (#47082) This commit adds a built-in Imgix loader for the NgOptimizedImage directive. If you provide the desired Imgix hostname, an ImageLoader will be generated with the correct options. Usage looks like this: ```ts providers: [ provideImgixLoader('https://some.imgix.net') ] ``` It sets the "auto=format" flag by default, which ensures that the smallest image format supported by the browser is served. This change also moves the IMAGE_LOADER, ImageLoader, and ImageLoaderConfig into a new directory that will be shared by all built-in image loaders. PR Close #47082 --- .../image_loaders/image_loader.ts | 39 +++++ .../image_loaders/imgix_loader.ts | 67 ++++++++ .../directives/ng_optimized_image/index.ts | 10 ++ .../ng_optimized_image.ts | 36 +---- packages/common/src/private_export.ts | 2 +- .../directives/ng_optimized_image_spec.ts | 3 +- .../test/image_loaders/image_loader_spec.ts | 148 ++++++++++++++++++ .../bundling/image-directive/playground.ts | 9 +- 8 files changed, 273 insertions(+), 41 deletions(-) create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts create mode 100644 packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts create mode 100644 packages/common/src/directives/ng_optimized_image/index.ts rename packages/common/src/directives/{ => ng_optimized_image}/ng_optimized_image.ts (93%) create mode 100644 packages/common/test/image_loaders/image_loader_spec.ts diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts new file mode 100644 index 0000000000000..fba4f28c8c5a1 --- /dev/null +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts @@ -0,0 +1,39 @@ +/** + * @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 {InjectionToken} from '@angular/core'; + +/** + * Config options recognized by the image loader function. + */ +export interface ImageLoaderConfig { + // Name of the image to be added to the image request URL + src: string; + // Width of the requested image (to be used when generating srcset) + width?: number; +} + +/** + * Represents an image loader function. + */ +export type ImageLoader = (config: ImageLoaderConfig) => string; + +/** + * Noop image loader that does no transformation to the original src and just returns it as is. + * This loader is used as a default one if more specific logic is not provided in an app config. + */ +const noopImageLoader = (config: ImageLoaderConfig) => config.src; + +/** + * Special token that allows to configure a function that will be used to produce an image URL based + * on the specified input. + */ +export const IMAGE_LOADER = new InjectionToken('ImageLoader', { + providedIn: 'root', + factory: () => noopImageLoader, +}); 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 new file mode 100644 index 0000000000000..caf87beecf4b4 --- /dev/null +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts @@ -0,0 +1,67 @@ +/** + * @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'; + +/** + * Function that generates a built-in ImageLoader for Imgix and turns it + * into an Angular provider. + * + * @param path path to the desired Imgix origin, + * e.g. https://somepath.imgix.net or https://images.mysite.com + * @returns Provider that provides an ImageLoader function + */ +export function provideImgixLoader(path: string): Provider { + ngDevMode && assertValidPath(path); + path = normalizePath(path); + + return { + provide: IMAGE_LOADER, + useValue: (config: ImageLoaderConfig) => { + const url = new URL(`${path}/${normalizeSrc(config.src)}`); + // This setting ensures the smallest allowable format is set. + url.searchParams.set('auto', 'format'); + config.width && url.searchParams.set('w', config.width.toString()); + return url.href; + } + }; +} + +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, + `ImgixLoader has detected an invalid path: ` + + `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/index.ts b/packages/common/src/directives/ng_optimized_image/index.ts new file mode 100644 index 0000000000000..78e213eb08c92 --- /dev/null +++ b/packages/common/src/directives/ng_optimized_image/index.ts @@ -0,0 +1,10 @@ +/** + * @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 + */ +export {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from './image_loaders/image_loader'; +export {provideImgixLoader} from './image_loaders/imgix_loader'; +export {NgOptimizedImage, NgOptimizedImageModule} from './ng_optimized_image'; diff --git a/packages/common/src/directives/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts similarity index 93% rename from packages/common/src/directives/ng_optimized_image.ts rename to packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts index f273de3649c9c..da5291a7ac96f 100644 --- a/packages/common/src/directives/ng_optimized_image.ts +++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts @@ -6,31 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, ElementRef, Inject, Injectable, InjectionToken, Injector, Input, NgModule, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core'; +import {Directive, ElementRef, Inject, Injectable, Injector, Input, NgModule, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core'; -import {DOCUMENT} from '../dom_tokens'; -import {RuntimeErrorCode} from '../errors'; +import {DOCUMENT} from '../../dom_tokens'; +import {RuntimeErrorCode} from '../../errors'; -/** - * Config options recognized by the image loader function. - */ -export interface ImageLoaderConfig { - // Name of the image to be added to the image request URL - src: string; - // Width of the requested image (to be used when generating srcset) - width?: number; -} - -/** - * Represents an image loader function. - */ -export type ImageLoader = (config: ImageLoaderConfig) => string; - -/** - * Noop image loader that does no transformation to the original src and just returns it as is. - * This loader is used as a default one if more specific logic is not provided in an app config. - */ -const noopImageLoader = (config: ImageLoaderConfig) => config.src; +import {IMAGE_LOADER, ImageLoader} from './image_loaders/image_loader'; /** * When a Base64-encoded image is passed as an input to the `NgOptimizedImage` directive, @@ -53,15 +34,6 @@ const VALID_WIDTH_DESCRIPTOR_SRCSET = /^((\s*\d+w\s*(,|$)){1,})$/; */ const VALID_DENSITY_DESCRIPTOR_SRCSET = /^((\s*\d(\.\d)?x\s*(,|$)){1,})$/; -/** - * Special token that allows to configure a function that will be used to produce an image URL based - * on the specified input. - */ -export const IMAGE_LOADER = new InjectionToken('ImageLoader', { - providedIn: 'root', - factory: () => noopImageLoader, -}); - /** * Contains the logic to detect whether an image with the `NgOptimizedImage` directive * is treated as an LCP element. If so, verifies that the image is marked as a priority, diff --git a/packages/common/src/private_export.ts b/packages/common/src/private_export.ts index e29f32fad3b14..8b7550e7700b3 100644 --- a/packages/common/src/private_export.ts +++ b/packages/common/src/private_export.ts @@ -6,6 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -export {IMAGE_LOADER as ɵIMAGE_LOADER, ImageLoaderConfig as ɵImageLoaderConfig, NgOptimizedImage as ɵNgOptimizedImage, NgOptimizedImageModule as ɵNgOptimizedImageModule} from './directives/ng_optimized_image'; +export {IMAGE_LOADER as ɵIMAGE_LOADER, ImageLoader as ɵImageLoader, ImageLoaderConfig as ɵImageLoaderConfig, NgOptimizedImage as ɵNgOptimizedImage, NgOptimizedImageModule as ɵNgOptimizedImageModule, provideImgixLoader as ɵprovideImgixLoader} from './directives/ng_optimized_image'; export {DomAdapter as ɵDomAdapter, getDOM as ɵgetDOM, setRootDomAdapter as ɵsetRootDomAdapter} from './dom_adapter'; export {BrowserPlatformLocation as ɵBrowserPlatformLocation} from './location/platform_location'; diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts index b1442aa09038b..5059739ed784d 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -7,7 +7,8 @@ */ import {CommonModule, DOCUMENT} from '@angular/common'; -import {assertValidRawSrcset, IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImageModule} from '@angular/common/src/directives/ng_optimized_image'; +import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from '@angular/common/src/directives/ng_optimized_image/image_loaders/image_loader'; +import {assertValidRawSrcset, NgOptimizedImageModule} from '@angular/common/src/directives/ng_optimized_image/ng_optimized_image'; import {RuntimeErrorCode} from '@angular/common/src/errors'; import {Component} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; diff --git a/packages/common/test/image_loaders/image_loader_spec.ts b/packages/common/test/image_loaders/image_loader_spec.ts new file mode 100644 index 0000000000000..ccb5f572c624a --- /dev/null +++ b/packages/common/test/image_loaders/image_loader_spec.ts @@ -0,0 +1,148 @@ +/** + * @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 {CommonModule} from '@angular/common'; +import {IMAGE_LOADER} from '@angular/common/src/directives/ng_optimized_image/image_loaders/image_loader'; +import {provideImgixLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imgix_loader'; +import {NgOptimizedImageModule} from '@angular/common/src/directives/ng_optimized_image/ng_optimized_image'; +import {RuntimeErrorCode} from '@angular/common/src/errors'; +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {expect} from '@angular/platform-browser/testing/src/matchers'; + +describe('Built-in image directive loaders', () => { + describe('Imgix loader', () => { + describe('invalid paths', () => { + it('should throw if path is empty', () => { + expect(() => { + setupTestingModule([provideImgixLoader('')]); + }) + .toThrowError( + `NG0${RuntimeErrorCode.INVALID_INPUT}: ImgixLoader has detected an invalid path: ` + + `expecting a path like https://somepath.imgix.net/` + + `but got: \`\``); + }); + + it('should throw if not a path', () => { + expect(() => { + setupTestingModule([provideImgixLoader('wellhellothere')]); + }) + .toThrowError( + `NG0${RuntimeErrorCode.INVALID_INPUT}: ImgixLoader has detected an invalid path: ` + + `expecting a path like https://somepath.imgix.net/` + + `but got: \`wellhellothere\``); + }); + + it('should throw if path is missing a scheme', () => { + expect(() => { + setupTestingModule([provideImgixLoader('somepath.imgix.net')]); + }) + .toThrowError( + `NG0${RuntimeErrorCode.INVALID_INPUT}: ImgixLoader has detected an invalid path: ` + + `expecting a path like https://somepath.imgix.net/` + + `but got: \`somepath.imgix.net\``); + }); + + it('should throw if path is malformed', () => { + expect(() => { + setupTestingModule([provideImgixLoader('somepa\th.imgix.net? few')]); + }) + .toThrowError( + `NG0${RuntimeErrorCode.INVALID_INPUT}: ImgixLoader has detected an invalid path: ` + + `expecting a path like https://somepath.imgix.net/` + + `but got: \`somepa\th.imgix.net? few\``); + }); + }); + + it('should construct an image loader with the given path', () => { + setupTestingModule([provideImgixLoader('https://somesite.imgix.net')]); + + const template = ` + + + `; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const imgs = nativeElement.querySelectorAll('img')!; + expect(imgs[0].src).toBe('https://somesite.imgix.net/img.png?auto=format'); + expect(imgs[1].src).toBe('https://somesite.imgix.net/img-2.png?auto=format'); + }); + + it('should handle a trailing forward slash on the path', () => { + setupTestingModule([provideImgixLoader('https://somesite.imgix.net/')]); + + const template = ` + + `; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.src).toBe('https://somesite.imgix.net/img.png?auto=format'); + }); + + it('should handle a leading forward slash on the src', () => { + setupTestingModule([provideImgixLoader('https://somesite.imgix.net/')]); + + const template = ` + + `; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.src).toBe('https://somesite.imgix.net/img.png?auto=format'); + }); + + it('should be compatible with rawSrcset', () => { + setupTestingModule([provideImgixLoader('https://somesite.imgix.net')]); + + const template = ` + + `; + const fixture = createTestComponent(template); + fixture.detectChanges(); + + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + expect(img.src).toBe('https://somesite.imgix.net/img.png?auto=format'); + expect(img.srcset) + .toBe( + 'https://somesite.imgix.net/img.png?auto=format&w=100 100w, https://somesite.imgix.net/img.png?auto=format&w=200 200w'); + }); + }); +}); + + +// Helpers + +@Component({ + selector: 'test-cmp', + template: '', +}) +class TestComponent { +} + +function setupTestingModule(providers: any[]) { + TestBed.configureTestingModule({ + declarations: [TestComponent], + // Note: the `NgOptimizedImage` directive is experimental and is not a part of the + // `CommonModule` yet, so it's imported separately. + imports: [CommonModule, NgOptimizedImageModule], + providers, + }); +} + +function createTestComponent(template: string): ComponentFixture { + return TestBed.overrideComponent(TestComponent, {set: {template: template}}) + .createComponent(TestComponent); +} diff --git a/packages/core/test/bundling/image-directive/playground.ts b/packages/core/test/bundling/image-directive/playground.ts index 8cee2d325de43..efbd3f5af0ac7 100644 --- a/packages/core/test/bundling/image-directive/playground.ts +++ b/packages/core/test/bundling/image-directive/playground.ts @@ -6,14 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {ɵIMAGE_LOADER as IMAGE_LOADER, ɵImageLoaderConfig as ImageLoaderConfig, ɵNgOptimizedImageModule as NgOptimizedImageModule} from '@angular/common'; +import {ɵIMAGE_LOADER as IMAGE_LOADER, ɵImageLoaderConfig as ImageLoaderConfig, ɵNgOptimizedImageModule as NgOptimizedImageModule, ɵprovideImgixLoader as provideImgixLoader} from '@angular/common'; import {Component} from '@angular/core'; -const CUSTOM_IMGIX_LOADER = (config: ImageLoaderConfig) => { - const widthStr = config.width ? `?w=${config.width}` : ``; - return `https://aurora-project.imgix.net/${config.src}${widthStr}`; -}; - @Component({ selector: 'basic', styles: [` @@ -49,7 +44,7 @@ const CUSTOM_IMGIX_LOADER = (config: ImageLoaderConfig) => { `, standalone: true, imports: [NgOptimizedImageModule], - providers: [{provide: IMAGE_LOADER, useValue: CUSTOM_IMGIX_LOADER}], + providers: [provideImgixLoader('https://aurora-project.imgix.net')], }) export class PlaygroundComponent { }