Skip to content

Commit 03c3b3e

Browse files
ascorbicthePunderWoman
authored andcommitted
feat(common): add Netlify image loader (angular#54311)
Add an image loader for Netlify Image CDN. It is slightly different in implementation from existing loaders, because it allows absolute URLs Fixes angular#54303 PR Close angular#54311
1 parent 9c2bad9 commit 03c3b3e

9 files changed

Lines changed: 194 additions & 2 deletions

File tree

adev/src/content/guide/image-optimization.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ Based on the image services commonly used with Angular applications, `NgOptimize
302302
| Cloudinary | `provideCloudinaryLoader` | [Documentation](https://cloudinary.com/documentation/resizing_and_cropping) |
303303
| ImageKit | `provideImageKitLoader` | [Documentation](https://docs.imagekit.io/) |
304304
| Imgix | `provideImgixLoader` | [Documentation](https://docs.imgix.com/) |
305+
| Netlify | `provideNetlifyLoader` | [Documentation](https://docs.netlify.com/image-cdn/overview/) |
305306

306307
To use the **generic loader** no additional code changes are necessary. This is the default behavior.
307308

aio/content/guide/image-directive.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ Based on the image services commonly used with Angular applications, `NgOptimize
295295
| Cloudinary | `provideCloudinaryLoader` | [Documentation](https://cloudinary.com/documentation/resizing_and_cropping) |
296296
| ImageKit | `provideImageKitLoader` | [Documentation](https://docs.imagekit.io/) |
297297
| Imgix | `provideImgixLoader` | [Documentation](https://docs.imgix.com/) |
298+
| Netlify | `provideNetlifyLoader` | [Documentation](https://docs.netlify.com/image-cdn/overview/) |
298299

299300
To use the **generic loader** no additional code changes are necessary. This is the default behavior.
300301

goldens/public-api/common/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,9 @@ export const provideImageKitLoader: (path: string) => Provider[];
889889
// @public
890890
export const provideImgixLoader: (path: string) => Provider[];
891891

892+
// @public
893+
export function provideNetlifyLoader(path?: string): Provider[];
894+
892895
// @public
893896
export function registerLocaleData(data: any, localeId?: string | any, extraData?: any): void;
894897

packages/common/src/common.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,6 @@ export {
112112
provideCloudinaryLoader,
113113
provideImageKitLoader,
114114
provideImgixLoader,
115+
provideNetlifyLoader,
115116
} from './directives/ng_optimized_image';
116117
export {normalizeQueryParams as ɵnormalizeQueryParams} from './location/util';
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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 {
10+
Provider,
11+
ɵformatRuntimeError as formatRuntimeError,
12+
ɵRuntimeError as RuntimeError,
13+
} from '@angular/core';
14+
15+
import {RuntimeErrorCode} from '../../../errors';
16+
import {isAbsoluteUrl, isValidPath} from '../url';
17+
18+
import {IMAGE_LOADER, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';
19+
20+
/**
21+
* Name and URL tester for Netlify.
22+
*/
23+
export const netlifyLoaderInfo: ImageLoaderInfo = {
24+
name: 'Netlify',
25+
testUrl: isNetlifyUrl,
26+
};
27+
28+
const NETLIFY_LOADER_REGEX = /https?\:\/\/[^\/]+\.netlify\.app\/.+/;
29+
30+
/**
31+
* Tests whether a URL is from a Netlify site. This won't catch sites with a custom domain,
32+
* but it's a good start for sites in development. This is only used to warn users who haven't
33+
* configured an image loader.
34+
*/
35+
function isNetlifyUrl(url: string): boolean {
36+
return NETLIFY_LOADER_REGEX.test(url);
37+
}
38+
39+
/**
40+
* Function that generates an ImageLoader for Netlify and turns it into an Angular provider.
41+
*
42+
* @param path optional URL of the desired Netlify site. Defaults to the current site.
43+
* @returns Set of providers to configure the Netlify loader.
44+
*
45+
* @publicApi
46+
*/
47+
export function provideNetlifyLoader(path?: string) {
48+
if (path && !isValidPath(path)) {
49+
throw new RuntimeError(
50+
RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
51+
ngDevMode &&
52+
`Image loader has detected an invalid path (\`${path}\`). ` +
53+
`To fix this, supply either the full URL to the Netlify site, or leave it empty to use the current site.`,
54+
);
55+
}
56+
57+
if (path) {
58+
const url = new URL(path);
59+
path = url.origin;
60+
}
61+
62+
const loaderFn = (config: ImageLoaderConfig) => {
63+
return createNetlifyUrl(config, path);
64+
};
65+
66+
const providers: Provider[] = [{provide: IMAGE_LOADER, useValue: loaderFn}];
67+
return providers;
68+
}
69+
70+
const validParams = new Map<string, string>([
71+
['height', 'h'],
72+
['fit', 'fit'],
73+
['quality', 'q'],
74+
['q', 'q'],
75+
['position', 'position'],
76+
]);
77+
78+
function createNetlifyUrl(config: ImageLoaderConfig, path?: string) {
79+
// Note: `path` can be undefined, in which case we use a fake one to construct a `URL` instance.
80+
const url = new URL(path ?? 'https://a/');
81+
url.pathname = '/.netlify/images';
82+
83+
if (!isAbsoluteUrl(config.src) && !config.src.startsWith('/')) {
84+
config.src = '/' + config.src;
85+
}
86+
87+
url.searchParams.set('url', config.src);
88+
89+
if (config.width) {
90+
url.searchParams.set('w', config.width.toString());
91+
}
92+
93+
for (const [param, value] of Object.entries(config.loaderParams ?? {})) {
94+
if (validParams.has(param)) {
95+
url.searchParams.set(validParams.get(param)!, value.toString());
96+
} else {
97+
if (ngDevMode) {
98+
console.warn(
99+
formatRuntimeError(
100+
RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
101+
`The Netlify image loader has detected an \`<img>\` tag with the unsupported attribute "\`${param}\`".`,
102+
),
103+
);
104+
}
105+
}
106+
}
107+
// The "a" hostname is used for relative URLs, so we can remove it from the final URL.
108+
return url.hostname === 'a' ? url.href.replace(url.origin, '') : url.href;
109+
}

packages/common/src/directives/ng_optimized_image/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ export {provideCloudinaryLoader} from './image_loaders/cloudinary_loader';
1313
export {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from './image_loaders/image_loader';
1414
export {provideImageKitLoader} from './image_loaders/imagekit_loader';
1515
export {provideImgixLoader} from './image_loaders/imgix_loader';
16+
export {provideNetlifyLoader} from './image_loaders/netlify_loader';
1617
export {ImagePlaceholderConfig, NgOptimizedImage} from './ng_optimized_image';
1718
export {PRECONNECT_CHECK_BLOCKLIST} from './preconnect_link_checker';

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from './image_loaders/image_loader';
4545
import {imageKitLoaderInfo} from './image_loaders/imagekit_loader';
4646
import {imgixLoaderInfo} from './image_loaders/imgix_loader';
47+
import {netlifyLoaderInfo} from './image_loaders/netlify_loader';
4748
import {LCPImageObserver} from './lcp_image_observer';
4849
import {PreconnectLinkChecker} from './preconnect_link_checker';
4950
import {PreloadLinkCreator} from './preload-link-creator';
@@ -128,7 +129,12 @@ export const DATA_URL_WARN_LIMIT = 4000;
128129
export const DATA_URL_ERROR_LIMIT = 10000;
129130

130131
/** Info about built-in loaders we can test for. */
131-
export const BUILT_IN_LOADERS = [imgixLoaderInfo, imageKitLoaderInfo, cloudinaryLoaderInfo];
132+
export const BUILT_IN_LOADERS = [
133+
imgixLoaderInfo,
134+
imageKitLoaderInfo,
135+
cloudinaryLoaderInfo,
136+
netlifyLoaderInfo,
137+
];
132138

133139
/**
134140
* Config options used in rendering placeholder images.

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,6 +1495,20 @@ describe('Image directive', () => {
14951495
);
14961496
});
14971497

1498+
it('should warn if there is no image loader but using Netlify URL', () => {
1499+
setUpModuleNoLoader();
1500+
1501+
const template = `<img ngSrc="https://example.netlify.app/img.png" width="100" height="50">`;
1502+
const fixture = createTestComponent(template);
1503+
const consoleWarnSpy = spyOn(console, 'warn');
1504+
fixture.detectChanges();
1505+
1506+
expect(consoleWarnSpy.calls.count()).toBe(1);
1507+
expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch(
1508+
/your images may be hosted on the Netlify CDN/,
1509+
);
1510+
});
1511+
14981512
it('should NOT warn if there is a custom loader but using CDN URL', () => {
14991513
setupTestingModule();
15001514

packages/common/test/image_loaders/image_loader_spec.ts

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

9-
import {IMAGE_LOADER, ImageLoader} from '@angular/common/src/directives/ng_optimized_image';
9+
import {
10+
IMAGE_LOADER,
11+
ImageLoader,
12+
provideNetlifyLoader,
13+
} from '@angular/common/src/directives/ng_optimized_image';
1014
import {provideCloudflareLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader';
1115
import {provideCloudinaryLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader';
1216
import {provideImageKitLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader';
1317
import {provideImgixLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imgix_loader';
1418
import {isValidPath} from '@angular/common/src/directives/ng_optimized_image/url';
19+
import {RuntimeErrorCode} from '@angular/common/src/errors';
1520
import {createEnvironmentInjector, EnvironmentInjector} from '@angular/core';
1621
import {TestBed} from '@angular/core/testing';
1722

@@ -207,6 +212,57 @@ describe('Built-in image directive loaders', () => {
207212
});
208213
});
209214

215+
describe('Netlify loader', () => {
216+
function createNetlifyLoader(path?: string): ImageLoader {
217+
const injector = createEnvironmentInjector(
218+
[provideNetlifyLoader(path)],
219+
TestBed.inject(EnvironmentInjector),
220+
);
221+
return injector.get(IMAGE_LOADER);
222+
}
223+
it('should construct an image loader with an empty path', () => {
224+
const loader = createNetlifyLoader();
225+
let config = {src: 'img.png'};
226+
expect(loader(config)).toBe('/.netlify/images?url=%2Fimg.png');
227+
});
228+
it('should construct an image loader with the given path', () => {
229+
const loader = createNetlifyLoader('https://mysite.com');
230+
let config = {src: 'img.png'};
231+
expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png');
232+
});
233+
it('should construct an image loader with the given path', () => {
234+
const loader = createNetlifyLoader('https://mysite.com');
235+
const config = {src: 'img.png', width: 100};
236+
expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png&w=100');
237+
});
238+
239+
it('should construct an image URL with custom options', () => {
240+
const loader = createNetlifyLoader('https://mysite.com');
241+
const config = {src: 'img.png', width: 100, loaderParams: {quality: 50}};
242+
expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png&w=100&q=50');
243+
});
244+
245+
it('should construct an image with an absolute URL', () => {
246+
const path = 'https://mysite.com';
247+
const src = 'https://angular.io/img.png';
248+
const loader = createNetlifyLoader(path);
249+
expect(loader({src})).toBe(
250+
'https://mysite.com/.netlify/images?url=https%3A%2F%2Fangular.io%2Fimg.png',
251+
);
252+
});
253+
254+
it('should warn if an unknown loader parameter is provided', () => {
255+
const path = 'https://mysite.com';
256+
const loader = createNetlifyLoader(path);
257+
const config = {src: 'img.png', loaderParams: {unknown: 'value'}};
258+
spyOn(console, 'warn');
259+
expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png');
260+
expect(console.warn).toHaveBeenCalledWith(
261+
`NG0${RuntimeErrorCode.INVALID_LOADER_ARGUMENTS}: The Netlify image loader has detected an \`<img>\` tag with the unsupported attribute "\`unknown\`".`,
262+
);
263+
});
264+
});
265+
210266
describe('loader utils', () => {
211267
it('should identify valid paths', () => {
212268
expect(isValidPath('https://cdn.imageprovider.com/image-test')).toBe(true);

0 commit comments

Comments
 (0)