Skip to content

Commit 7ce497e

Browse files
karaPawel Kozlowski
authored andcommitted
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
1 parent e34e48c commit 7ce497e

File tree

8 files changed

+273
-41
lines changed

8 files changed

+273
-41
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
11+
/**
12+
* Config options recognized by the image loader function.
13+
*/
14+
export interface ImageLoaderConfig {
15+
// Name of the image to be added to the image request URL
16+
src: string;
17+
// Width of the requested image (to be used when generating srcset)
18+
width?: number;
19+
}
20+
21+
/**
22+
* Represents an image loader function.
23+
*/
24+
export type ImageLoader = (config: ImageLoaderConfig) => string;
25+
26+
/**
27+
* Noop image loader that does no transformation to the original src and just returns it as is.
28+
* This loader is used as a default one if more specific logic is not provided in an app config.
29+
*/
30+
const noopImageLoader = (config: ImageLoaderConfig) => config.src;
31+
32+
/**
33+
* Special token that allows to configure a function that will be used to produce an image URL based
34+
* on the specified input.
35+
*/
36+
export const IMAGE_LOADER = new InjectionToken<ImageLoader>('ImageLoader', {
37+
providedIn: 'root',
38+
factory: () => noopImageLoader,
39+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Provider, ɵRuntimeError as RuntimeError} from '@angular/core';
10+
11+
import {RuntimeErrorCode} from '../../../errors';
12+
13+
import {IMAGE_LOADER, ImageLoaderConfig} from './image_loader';
14+
15+
/**
16+
* Function that generates a built-in ImageLoader for Imgix and turns it
17+
* into an Angular provider.
18+
*
19+
* @param path path to the desired Imgix origin,
20+
* e.g. https://somepath.imgix.net or https://images.mysite.com
21+
* @returns Provider that provides an ImageLoader function
22+
*/
23+
export function provideImgixLoader(path: string): Provider {
24+
ngDevMode && assertValidPath(path);
25+
path = normalizePath(path);
26+
27+
return {
28+
provide: IMAGE_LOADER,
29+
useValue: (config: ImageLoaderConfig) => {
30+
const url = new URL(`${path}/${normalizeSrc(config.src)}`);
31+
// This setting ensures the smallest allowable format is set.
32+
url.searchParams.set('auto', 'format');
33+
config.width && url.searchParams.set('w', config.width.toString());
34+
return url.href;
35+
}
36+
};
37+
}
38+
39+
function assertValidPath(path: unknown) {
40+
const isString = typeof path === 'string';
41+
42+
if (!isString || path.trim() === '') {
43+
throwInvalidPathError(path);
44+
}
45+
46+
try {
47+
const url = new URL(path);
48+
} catch {
49+
throwInvalidPathError(path);
50+
}
51+
}
52+
53+
function throwInvalidPathError(path: unknown): never {
54+
throw new RuntimeError(
55+
RuntimeErrorCode.INVALID_INPUT,
56+
`ImgixLoader has detected an invalid path: ` +
57+
`expecting a path like https://somepath.imgix.net/` +
58+
`but got: \`${path}\``);
59+
}
60+
61+
function normalizePath(path: string) {
62+
return path[path.length - 1] === '/' ? path.slice(0, -1) : path;
63+
}
64+
65+
function normalizeSrc(src: string) {
66+
return src[0] === '/' ? src.slice(1) : src;
67+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
export {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from './image_loaders/image_loader';
9+
export {provideImgixLoader} from './image_loaders/imgix_loader';
10+
export {NgOptimizedImage, NgOptimizedImageModule} from './ng_optimized_image';

packages/common/src/directives/ng_optimized_image.ts renamed to packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,12 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive, ElementRef, Inject, Injectable, InjectionToken, Injector, Input, NgModule, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
9+
import {Directive, ElementRef, Inject, Injectable, Injector, Input, NgModule, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
1010

11-
import {DOCUMENT} from '../dom_tokens';
12-
import {RuntimeErrorCode} from '../errors';
11+
import {DOCUMENT} from '../../dom_tokens';
12+
import {RuntimeErrorCode} from '../../errors';
1313

14-
/**
15-
* Config options recognized by the image loader function.
16-
*/
17-
export interface ImageLoaderConfig {
18-
// Name of the image to be added to the image request URL
19-
src: string;
20-
// Width of the requested image (to be used when generating srcset)
21-
width?: number;
22-
}
23-
24-
/**
25-
* Represents an image loader function.
26-
*/
27-
export type ImageLoader = (config: ImageLoaderConfig) => string;
28-
29-
/**
30-
* Noop image loader that does no transformation to the original src and just returns it as is.
31-
* This loader is used as a default one if more specific logic is not provided in an app config.
32-
*/
33-
const noopImageLoader = (config: ImageLoaderConfig) => config.src;
14+
import {IMAGE_LOADER, ImageLoader} from './image_loaders/image_loader';
3415

3516
/**
3617
* 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,})$/;
5334
*/
5435
const VALID_DENSITY_DESCRIPTOR_SRCSET = /^((\s*\d(\.\d)?x\s*(,|$)){1,})$/;
5536

56-
/**
57-
* Special token that allows to configure a function that will be used to produce an image URL based
58-
* on the specified input.
59-
*/
60-
export const IMAGE_LOADER = new InjectionToken<ImageLoader>('ImageLoader', {
61-
providedIn: 'root',
62-
factory: () => noopImageLoader,
63-
});
64-
6537
/**
6638
* Contains the logic to detect whether an image with the `NgOptimizedImage` directive
6739
* is treated as an LCP element. If so, verifies that the image is marked as a priority,

packages/common/src/private_export.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
export {IMAGE_LOADER as ɵIMAGE_LOADER, ImageLoaderConfig as ɵImageLoaderConfig, NgOptimizedImage as ɵNgOptimizedImage, NgOptimizedImageModule as ɵNgOptimizedImageModule} from './directives/ng_optimized_image';
9+
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';
1010
export {DomAdapter as ɵDomAdapter, getDOM as ɵgetDOM, setRootDomAdapter as ɵsetRootDomAdapter} from './dom_adapter';
1111
export {BrowserPlatformLocation as ɵBrowserPlatformLocation} from './location/platform_location';

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88

99
import {CommonModule, DOCUMENT} from '@angular/common';
10-
import {assertValidRawSrcset, IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImageModule} from '@angular/common/src/directives/ng_optimized_image';
10+
import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from '@angular/common/src/directives/ng_optimized_image/image_loaders/image_loader';
11+
import {assertValidRawSrcset, NgOptimizedImageModule} from '@angular/common/src/directives/ng_optimized_image/ng_optimized_image';
1112
import {RuntimeErrorCode} from '@angular/common/src/errors';
1213
import {Component} from '@angular/core';
1314
import {ComponentFixture, TestBed} from '@angular/core/testing';
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {CommonModule} from '@angular/common';
10+
import {IMAGE_LOADER} from '@angular/common/src/directives/ng_optimized_image/image_loaders/image_loader';
11+
import {provideImgixLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imgix_loader';
12+
import {NgOptimizedImageModule} from '@angular/common/src/directives/ng_optimized_image/ng_optimized_image';
13+
import {RuntimeErrorCode} from '@angular/common/src/errors';
14+
import {Component} from '@angular/core';
15+
import {ComponentFixture, TestBed} from '@angular/core/testing';
16+
import {expect} from '@angular/platform-browser/testing/src/matchers';
17+
18+
describe('Built-in image directive loaders', () => {
19+
describe('Imgix loader', () => {
20+
describe('invalid paths', () => {
21+
it('should throw if path is empty', () => {
22+
expect(() => {
23+
setupTestingModule([provideImgixLoader('')]);
24+
})
25+
.toThrowError(
26+
`NG0${RuntimeErrorCode.INVALID_INPUT}: ImgixLoader has detected an invalid path: ` +
27+
`expecting a path like https://somepath.imgix.net/` +
28+
`but got: \`\``);
29+
});
30+
31+
it('should throw if not a path', () => {
32+
expect(() => {
33+
setupTestingModule([provideImgixLoader('wellhellothere')]);
34+
})
35+
.toThrowError(
36+
`NG0${RuntimeErrorCode.INVALID_INPUT}: ImgixLoader has detected an invalid path: ` +
37+
`expecting a path like https://somepath.imgix.net/` +
38+
`but got: \`wellhellothere\``);
39+
});
40+
41+
it('should throw if path is missing a scheme', () => {
42+
expect(() => {
43+
setupTestingModule([provideImgixLoader('somepath.imgix.net')]);
44+
})
45+
.toThrowError(
46+
`NG0${RuntimeErrorCode.INVALID_INPUT}: ImgixLoader has detected an invalid path: ` +
47+
`expecting a path like https://somepath.imgix.net/` +
48+
`but got: \`somepath.imgix.net\``);
49+
});
50+
51+
it('should throw if path is malformed', () => {
52+
expect(() => {
53+
setupTestingModule([provideImgixLoader('somepa\th.imgix.net? few')]);
54+
})
55+
.toThrowError(
56+
`NG0${RuntimeErrorCode.INVALID_INPUT}: ImgixLoader has detected an invalid path: ` +
57+
`expecting a path like https://somepath.imgix.net/` +
58+
`but got: \`somepa\th.imgix.net? few\``);
59+
});
60+
});
61+
62+
it('should construct an image loader with the given path', () => {
63+
setupTestingModule([provideImgixLoader('https://somesite.imgix.net')]);
64+
65+
const template = `
66+
<img rawSrc="img.png" width="150" height="50">
67+
<img rawSrc="img-2.png" width="150" height="50">
68+
`;
69+
const fixture = createTestComponent(template);
70+
fixture.detectChanges();
71+
72+
const nativeElement = fixture.nativeElement as HTMLElement;
73+
const imgs = nativeElement.querySelectorAll('img')!;
74+
expect(imgs[0].src).toBe('https://somesite.imgix.net/img.png?auto=format');
75+
expect(imgs[1].src).toBe('https://somesite.imgix.net/img-2.png?auto=format');
76+
});
77+
78+
it('should handle a trailing forward slash on the path', () => {
79+
setupTestingModule([provideImgixLoader('https://somesite.imgix.net/')]);
80+
81+
const template = `
82+
<img rawSrc="img.png" width="150" height="50">
83+
`;
84+
const fixture = createTestComponent(template);
85+
fixture.detectChanges();
86+
87+
const nativeElement = fixture.nativeElement as HTMLElement;
88+
const img = nativeElement.querySelector('img')!;
89+
expect(img.src).toBe('https://somesite.imgix.net/img.png?auto=format');
90+
});
91+
92+
it('should handle a leading forward slash on the src', () => {
93+
setupTestingModule([provideImgixLoader('https://somesite.imgix.net/')]);
94+
95+
const template = `
96+
<img rawSrc="/img.png" width="150" height="50">
97+
`;
98+
const fixture = createTestComponent(template);
99+
fixture.detectChanges();
100+
101+
const nativeElement = fixture.nativeElement as HTMLElement;
102+
const img = nativeElement.querySelector('img')!;
103+
expect(img.src).toBe('https://somesite.imgix.net/img.png?auto=format');
104+
});
105+
106+
it('should be compatible with rawSrcset', () => {
107+
setupTestingModule([provideImgixLoader('https://somesite.imgix.net')]);
108+
109+
const template = `
110+
<img rawSrc="img.png" rawSrcset="100w, 200w" width="100" height="50">
111+
`;
112+
const fixture = createTestComponent(template);
113+
fixture.detectChanges();
114+
115+
const nativeElement = fixture.nativeElement as HTMLElement;
116+
const img = nativeElement.querySelector('img')!;
117+
expect(img.src).toBe('https://somesite.imgix.net/img.png?auto=format');
118+
expect(img.srcset)
119+
.toBe(
120+
'https://somesite.imgix.net/img.png?auto=format&w=100 100w, https://somesite.imgix.net/img.png?auto=format&w=200 200w');
121+
});
122+
});
123+
});
124+
125+
126+
// Helpers
127+
128+
@Component({
129+
selector: 'test-cmp',
130+
template: '',
131+
})
132+
class TestComponent {
133+
}
134+
135+
function setupTestingModule(providers: any[]) {
136+
TestBed.configureTestingModule({
137+
declarations: [TestComponent],
138+
// Note: the `NgOptimizedImage` directive is experimental and is not a part of the
139+
// `CommonModule` yet, so it's imported separately.
140+
imports: [CommonModule, NgOptimizedImageModule],
141+
providers,
142+
});
143+
}
144+
145+
function createTestComponent(template: string): ComponentFixture<TestComponent> {
146+
return TestBed.overrideComponent(TestComponent, {set: {template: template}})
147+
.createComponent(TestComponent);
148+
}

packages/core/test/bundling/image-directive/playground.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ɵIMAGE_LOADER as IMAGE_LOADER, ɵImageLoaderConfig as ImageLoaderConfig, ɵNgOptimizedImageModule as NgOptimizedImageModule} from '@angular/common';
9+
import {ɵIMAGE_LOADER as IMAGE_LOADER, ɵImageLoaderConfig as ImageLoaderConfig, ɵNgOptimizedImageModule as NgOptimizedImageModule, ɵprovideImgixLoader as provideImgixLoader} from '@angular/common';
1010
import {Component} from '@angular/core';
1111

12-
const CUSTOM_IMGIX_LOADER = (config: ImageLoaderConfig) => {
13-
const widthStr = config.width ? `?w=${config.width}` : ``;
14-
return `https://aurora-project.imgix.net/${config.src}${widthStr}`;
15-
};
16-
1712
@Component({
1813
selector: 'basic',
1914
styles: [`
@@ -49,7 +44,7 @@ const CUSTOM_IMGIX_LOADER = (config: ImageLoaderConfig) => {
4944
`,
5045
standalone: true,
5146
imports: [NgOptimizedImageModule],
52-
providers: [{provide: IMAGE_LOADER, useValue: CUSTOM_IMGIX_LOADER}],
47+
providers: [provideImgixLoader('https://aurora-project.imgix.net')],
5348
})
5449
export class PlaygroundComponent {
5550
}

0 commit comments

Comments
 (0)