Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
8 changed files
with
273 additions
and
41 deletions.
There are no files selected for viewing
39 changes: 39 additions & 0 deletions
39
packages/common/src/directives/ng_optimized_image/image_loaders/image_loader.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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>('ImageLoader', { | ||
providedIn: 'root', | ||
factory: () => noopImageLoader, | ||
}); |
67 changes: 67 additions & 0 deletions
67
packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
10 changes: 10 additions & 0 deletions
10
packages/common/src/directives/ng_optimized_image/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
148 changes: 148 additions & 0 deletions
148
packages/common/test/image_loaders/image_loader_spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 = ` | ||
<img rawSrc="img.png" width="150" height="50"> | ||
<img rawSrc="img-2.png" width="150" height="50"> | ||
`; | ||
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 = ` | ||
<img rawSrc="img.png" width="150" height="50"> | ||
`; | ||
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 = ` | ||
<img rawSrc="/img.png" width="150" height="50"> | ||
`; | ||
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 = ` | ||
<img rawSrc="img.png" rawSrcset="100w, 200w" width="100" height="50"> | ||
`; | ||
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<TestComponent> { | ||
return TestBed.overrideComponent(TestComponent, {set: {template: template}}) | ||
.createComponent(TestComponent); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters