Skip to content

Commit

Permalink
feat(common): add Netlify image loader (#54311)
Browse files Browse the repository at this point in the history
Add an image loader for Netlify Image CDN. It is slightly different in implementation from existing loaders, because it allows absolute URLs

Fixes #54303

PR Close #54311
  • Loading branch information
ascorbic authored and thePunderWoman committed Feb 8, 2024
1 parent 9c2bad9 commit 03c3b3e
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 2 deletions.
1 change: 1 addition & 0 deletions adev/src/content/guide/image-optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ Based on the image services commonly used with Angular applications, `NgOptimize
| Cloudinary | `provideCloudinaryLoader` | [Documentation](https://cloudinary.com/documentation/resizing_and_cropping) |
| ImageKit | `provideImageKitLoader` | [Documentation](https://docs.imagekit.io/) |
| Imgix | `provideImgixLoader` | [Documentation](https://docs.imgix.com/) |
| Netlify | `provideNetlifyLoader` | [Documentation](https://docs.netlify.com/image-cdn/overview/) |

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

Expand Down
1 change: 1 addition & 0 deletions aio/content/guide/image-directive.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ Based on the image services commonly used with Angular applications, `NgOptimize
| Cloudinary | `provideCloudinaryLoader` | [Documentation](https://cloudinary.com/documentation/resizing_and_cropping) |
| ImageKit | `provideImageKitLoader` | [Documentation](https://docs.imagekit.io/) |
| Imgix | `provideImgixLoader` | [Documentation](https://docs.imgix.com/) |
| Netlify | `provideNetlifyLoader` | [Documentation](https://docs.netlify.com/image-cdn/overview/) |

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

Expand Down
3 changes: 3 additions & 0 deletions goldens/public-api/common/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,9 @@ export const provideImageKitLoader: (path: string) => Provider[];
// @public
export const provideImgixLoader: (path: string) => Provider[];

// @public
export function provideNetlifyLoader(path?: string): Provider[];

// @public
export function registerLocaleData(data: any, localeId?: string | any, extraData?: any): void;

Expand Down
1 change: 1 addition & 0 deletions packages/common/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,6 @@ export {
provideCloudinaryLoader,
provideImageKitLoader,
provideImgixLoader,
provideNetlifyLoader,
} from './directives/ng_optimized_image';
export {normalizeQueryParams as ɵnormalizeQueryParams} from './location/util';
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @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,
ɵformatRuntimeError as formatRuntimeError,
ɵRuntimeError as RuntimeError,
} from '@angular/core';

import {RuntimeErrorCode} from '../../../errors';
import {isAbsoluteUrl, isValidPath} from '../url';

import {IMAGE_LOADER, ImageLoaderConfig, ImageLoaderInfo} from './image_loader';

/**
* Name and URL tester for Netlify.
*/
export const netlifyLoaderInfo: ImageLoaderInfo = {
name: 'Netlify',
testUrl: isNetlifyUrl,
};

const NETLIFY_LOADER_REGEX = /https?\:\/\/[^\/]+\.netlify\.app\/.+/;

/**
* Tests whether a URL is from a Netlify site. This won't catch sites with a custom domain,
* but it's a good start for sites in development. This is only used to warn users who haven't
* configured an image loader.
*/
function isNetlifyUrl(url: string): boolean {
return NETLIFY_LOADER_REGEX.test(url);
}

/**
* Function that generates an ImageLoader for Netlify and turns it into an Angular provider.
*
* @param path optional URL of the desired Netlify site. Defaults to the current site.
* @returns Set of providers to configure the Netlify loader.
*
* @publicApi
*/
export function provideNetlifyLoader(path?: string) {
if (path && !isValidPath(path)) {
throw new RuntimeError(
RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
ngDevMode &&
`Image loader has detected an invalid path (\`${path}\`). ` +
`To fix this, supply either the full URL to the Netlify site, or leave it empty to use the current site.`,
);
}

if (path) {
const url = new URL(path);
path = url.origin;
}

const loaderFn = (config: ImageLoaderConfig) => {
return createNetlifyUrl(config, path);
};

const providers: Provider[] = [{provide: IMAGE_LOADER, useValue: loaderFn}];
return providers;
}

const validParams = new Map<string, string>([
['height', 'h'],
['fit', 'fit'],
['quality', 'q'],
['q', 'q'],
['position', 'position'],
]);

function createNetlifyUrl(config: ImageLoaderConfig, path?: string) {
// Note: `path` can be undefined, in which case we use a fake one to construct a `URL` instance.
const url = new URL(path ?? 'https://a/');
url.pathname = '/.netlify/images';

if (!isAbsoluteUrl(config.src) && !config.src.startsWith('/')) {
config.src = '/' + config.src;
}

url.searchParams.set('url', config.src);

if (config.width) {
url.searchParams.set('w', config.width.toString());
}

for (const [param, value] of Object.entries(config.loaderParams ?? {})) {
if (validParams.has(param)) {
url.searchParams.set(validParams.get(param)!, value.toString());
} else {
if (ngDevMode) {
console.warn(
formatRuntimeError(
RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
`The Netlify image loader has detected an \`<img>\` tag with the unsupported attribute "\`${param}\`".`,
),
);
}
}
}
// The "a" hostname is used for relative URLs, so we can remove it from the final URL.
return url.hostname === 'a' ? url.href.replace(url.origin, '') : url.href;
}
1 change: 1 addition & 0 deletions packages/common/src/directives/ng_optimized_image/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ export {provideCloudinaryLoader} from './image_loaders/cloudinary_loader';
export {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from './image_loaders/image_loader';
export {provideImageKitLoader} from './image_loaders/imagekit_loader';
export {provideImgixLoader} from './image_loaders/imgix_loader';
export {provideNetlifyLoader} from './image_loaders/netlify_loader';
export {ImagePlaceholderConfig, NgOptimizedImage} from './ng_optimized_image';
export {PRECONNECT_CHECK_BLOCKLIST} from './preconnect_link_checker';
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from './image_loaders/image_loader';
import {imageKitLoaderInfo} from './image_loaders/imagekit_loader';
import {imgixLoaderInfo} from './image_loaders/imgix_loader';
import {netlifyLoaderInfo} from './image_loaders/netlify_loader';
import {LCPImageObserver} from './lcp_image_observer';
import {PreconnectLinkChecker} from './preconnect_link_checker';
import {PreloadLinkCreator} from './preload-link-creator';
Expand Down Expand Up @@ -128,7 +129,12 @@ export const DATA_URL_WARN_LIMIT = 4000;
export const DATA_URL_ERROR_LIMIT = 10000;

/** Info about built-in loaders we can test for. */
export const BUILT_IN_LOADERS = [imgixLoaderInfo, imageKitLoaderInfo, cloudinaryLoaderInfo];
export const BUILT_IN_LOADERS = [
imgixLoaderInfo,
imageKitLoaderInfo,
cloudinaryLoaderInfo,
netlifyLoaderInfo,
];

/**
* Config options used in rendering placeholder images.
Expand Down
14 changes: 14 additions & 0 deletions packages/common/test/directives/ng_optimized_image_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1495,6 +1495,20 @@ describe('Image directive', () => {
);
});

it('should warn if there is no image loader but using Netlify URL', () => {
setUpModuleNoLoader();

const template = `<img ngSrc="https://example.netlify.app/img.png" width="100" height="50">`;
const fixture = createTestComponent(template);
const consoleWarnSpy = spyOn(console, 'warn');
fixture.detectChanges();

expect(consoleWarnSpy.calls.count()).toBe(1);
expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch(
/your images may be hosted on the Netlify CDN/,
);
});

it('should NOT warn if there is a custom loader but using CDN URL', () => {
setupTestingModule();

Expand Down
58 changes: 57 additions & 1 deletion packages/common/test/image_loaders/image_loader_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@
* found in the LICENSE file at https://angular.io/license
*/

import {IMAGE_LOADER, ImageLoader} from '@angular/common/src/directives/ng_optimized_image';
import {
IMAGE_LOADER,
ImageLoader,
provideNetlifyLoader,
} from '@angular/common/src/directives/ng_optimized_image';
import {provideCloudflareLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader';
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 {isValidPath} from '@angular/common/src/directives/ng_optimized_image/url';
import {RuntimeErrorCode} from '@angular/common/src/errors';
import {createEnvironmentInjector, EnvironmentInjector} from '@angular/core';
import {TestBed} from '@angular/core/testing';

Expand Down Expand Up @@ -207,6 +212,57 @@ describe('Built-in image directive loaders', () => {
});
});

describe('Netlify loader', () => {
function createNetlifyLoader(path?: string): ImageLoader {
const injector = createEnvironmentInjector(
[provideNetlifyLoader(path)],
TestBed.inject(EnvironmentInjector),
);
return injector.get(IMAGE_LOADER);
}
it('should construct an image loader with an empty path', () => {
const loader = createNetlifyLoader();
let config = {src: 'img.png'};
expect(loader(config)).toBe('/.netlify/images?url=%2Fimg.png');
});
it('should construct an image loader with the given path', () => {
const loader = createNetlifyLoader('https://mysite.com');
let config = {src: 'img.png'};
expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png');
});
it('should construct an image loader with the given path', () => {
const loader = createNetlifyLoader('https://mysite.com');
const config = {src: 'img.png', width: 100};
expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png&w=100');
});

it('should construct an image URL with custom options', () => {
const loader = createNetlifyLoader('https://mysite.com');
const config = {src: 'img.png', width: 100, loaderParams: {quality: 50}};
expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png&w=100&q=50');
});

it('should construct an image with an absolute URL', () => {
const path = 'https://mysite.com';
const src = 'https://angular.io/img.png';
const loader = createNetlifyLoader(path);
expect(loader({src})).toBe(
'https://mysite.com/.netlify/images?url=https%3A%2F%2Fangular.io%2Fimg.png',
);
});

it('should warn if an unknown loader parameter is provided', () => {
const path = 'https://mysite.com';
const loader = createNetlifyLoader(path);
const config = {src: 'img.png', loaderParams: {unknown: 'value'}};
spyOn(console, 'warn');
expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png');
expect(console.warn).toHaveBeenCalledWith(
`NG0${RuntimeErrorCode.INVALID_LOADER_ARGUMENTS}: The Netlify image loader has detected an \`<img>\` tag with the unsupported attribute "\`unknown\`".`,
);
});
});

describe('loader utils', () => {
it('should identify valid paths', () => {
expect(isValidPath('https://cdn.imageprovider.com/image-test')).toBe(true);
Expand Down

0 comments on commit 03c3b3e

Please sign in to comment.