diff --git a/goldens/public-api/common/errors.md b/goldens/public-api/common/errors.md index e24e5d7adf226..92b257c65a96e 100644 --- a/goldens/public-api/common/errors.md +++ b/goldens/public-api/common/errors.md @@ -15,6 +15,8 @@ export const enum RuntimeErrorCode { // (undocumented) LCP_IMG_MISSING_PRIORITY = 2955, // (undocumented) + MISSING_BUILTIN_LOADER = 2962, + // (undocumented) NG_FOR_MISSING_DIFFER = -2200, // (undocumented) OVERSIZED_IMAGE = 2960, diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts index 6f233c30dbe35..7bb41b54a3a56 100644 --- a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts @@ -6,7 +6,23 @@ * found in the LICENSE file at https://angular.io/license */ -import {createImageLoader, ImageLoaderConfig} from './image_loader'; +import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader'; + +/** + * Name and URL tester for Cloudinary. + */ +export const cloudinaryLoaderInfo: ImageLoaderInfo = { + name: 'Cloudinary', + testUrl: isCloudinaryUrl +}; + +const CLOUDINARY_LOADER_REGEX = /https?\:\/\/[^\/]+\.cloudinary\.com\/.+/; +/** + * Tests whether a URL is from Cloudinary CDN. + */ +function isCloudinaryUrl(url: string): boolean { + return CLOUDINARY_LOADER_REGEX.test(url); +} /** * Function that generates an ImageLoader for Cloudinary and turns it into an Angular provider. 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 index 382d2f020f43b..4fe7d9383c84b 100644 --- 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 @@ -46,7 +46,15 @@ export type ImageLoader = (config: ImageLoaderConfig) => string; * @see `ImageLoader` * @see `NgOptimizedImage` */ -const noopImageLoader = (config: ImageLoaderConfig) => config.src; +export const noopImageLoader = (config: ImageLoaderConfig) => config.src; + +/** + * Metadata about the image loader. + */ +export type ImageLoaderInfo = { + name: string, + testUrl: (url: string) => boolean +}; /** * Injection token that configures the image loader function. diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts index 27c5795390dcd..7f5dbb758e714 100644 --- a/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts @@ -6,7 +6,23 @@ * found in the LICENSE file at https://angular.io/license */ -import {createImageLoader, ImageLoaderConfig} from './image_loader'; +import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader'; + +/** + * Name and URL tester for ImageKit. + */ +export const imageKitLoaderInfo: ImageLoaderInfo = { + name: 'ImageKit', + testUrl: isImageKitUrl +}; + +const IMAGE_KIT_LOADER_REGEX = /https?\:\/\/[^\/]+\.imagekit\.io\/.+/; +/** + * Tests whether a URL is from ImageKit CDN. + */ +function isImageKitUrl(url: string): boolean { + return IMAGE_KIT_LOADER_REGEX.test(url); +} /** * Function that generates an ImageLoader for ImageKit and turns it into an Angular provider. 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 index 4e7f89a4eda45..f3ce35a6fbd53 100644 --- 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 @@ -6,7 +6,23 @@ * found in the LICENSE file at https://angular.io/license */ -import {createImageLoader, ImageLoaderConfig} from './image_loader'; +import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader'; + +/** + * Name and URL tester for Imgix. + */ +export const imgixLoaderInfo: ImageLoaderInfo = { + name: 'Imgix', + testUrl: isImgixUrl +}; + +const IMGIX_LOADER_REGEX = /https?\:\/\/[^\/]+\.imgix\.net\/.+/; +/** + * Tests whether a URL is from Imgix CDN. + */ +function isImgixUrl(url: string): boolean { + return IMGIX_LOADER_REGEX.test(url); +} /** * Function that generates an ImageLoader for Imgix and turns it into an Angular provider. diff --git a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts index d645a97e9fd7e..1f8d9d0061917 100644 --- a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts +++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts @@ -12,7 +12,10 @@ import {RuntimeErrorCode} from '../../errors'; import {isPlatformServer} from '../../platform_id'; import {imgDirectiveDetails} from './error_helper'; -import {IMAGE_LOADER} from './image_loaders/image_loader'; +import {cloudinaryLoaderInfo} from './image_loaders/cloudinary_loader'; +import {IMAGE_LOADER, ImageLoader, noopImageLoader} from './image_loaders/image_loader'; +import {imageKitLoaderInfo} from './image_loaders/imagekit_loader'; +import {imgixLoaderInfo} from './image_loaders/imgix_loader'; import {LCPImageObserver} from './lcp_image_observer'; import {PreconnectLinkChecker} from './preconnect_link_checker'; import {PreloadLinkCreator} from './preload-link-creator'; @@ -72,6 +75,9 @@ const ASPECT_RATIO_TOLERANCE = .1; */ const OVERSIZED_IMAGE_TOLERANCE = 1000; +/** Info about built-in loaders we can test for. */ +export const BUILT_IN_LOADERS = [imgixLoaderInfo, imageKitLoaderInfo, cloudinaryLoaderInfo]; + /** * A configuration object for the NgOptimizedImage directive. Contains: * - breakpoints: An array of integer breakpoints used to generate @@ -385,6 +391,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { if (!this.ngSrcset) { assertNoComplexSizes(this); } + assertNotMissingBuiltInLoader(this.ngSrc, this.imageLoader); if (this.priority) { const checker = this.injector.get(PreconnectLinkChecker); checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc); @@ -873,3 +880,35 @@ function assertValidLoadingInput(dir: NgOptimizedImage) { `To fix this, provide a valid value ("lazy", "eager", or "auto").`); } } + +/** + * Warns if NOT using a loader (falling back to the generic loader) and + * the image appears to be hosted on one of the image CDNs for which + * we do have a built-in image loader. Suggests switching to the + * built-in loader. + * + * @param ngSrc Value of the ngSrc attribute + * @param imageLoader ImageLoader provided + */ +function assertNotMissingBuiltInLoader(ngSrc: string, imageLoader: ImageLoader) { + if (imageLoader === noopImageLoader) { + let builtInLoaderName = ''; + for (const loader of BUILT_IN_LOADERS) { + if (loader.testUrl(ngSrc)) { + builtInLoaderName = loader.name; + break; + } + } + if (builtInLoaderName) { + console.warn(formatRuntimeError( + RuntimeErrorCode.MISSING_BUILTIN_LOADER, + `NgOptimizedImage: It looks like your images may be hosted on the ` + + `${builtInLoaderName} CDN, but your app is not using Angular's ` + + `built-in loader for that CDN. We recommend switching to use ` + + `the built-in by calling \`provide${builtInLoaderName}Loader()\` ` + + `in your \`providers\` and passing it your instance's base URL. ` + + `If you don't want to use the built-in loader, define a custom ` + + `loader function using IMAGE_LOADER to silence this warning.`)); + } + } +} diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts index d8a3f06ca97f8..d088c0bc160c1 100644 --- a/packages/common/src/errors.ts +++ b/packages/common/src/errors.ts @@ -32,4 +32,5 @@ export const enum RuntimeErrorCode { INVALID_LOADER_ARGUMENTS = 2959, OVERSIZED_IMAGE = 2960, TOO_MANY_PRELOADED_IMAGES = 2961, + MISSING_BUILTIN_LOADER = 2962, } diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts index 8870960cee715..9301487e7e4ff 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -1097,6 +1097,56 @@ describe('Image directive', () => { expect(img.src).toBe(`${IMG_BASE_URL}/img.png`); }); + it('should warn if there is no image loader but using Imgix URL', () => { + setUpModuleNoLoader(); + + const template = ``; + 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 Imgix CDN/); + }); + + it('should warn if there is no image loader but using ImageKit URL', () => { + setUpModuleNoLoader(); + + const template = ``; + 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 ImageKit CDN/); + }); + + it('should warn if there is no image loader but using Cloudinary URL', () => { + setUpModuleNoLoader(); + + const template = ``; + 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 Cloudinary CDN/); + }); + + it('should NOT warn if there is a custom loader but using CDN URL', () => { + setupTestingModule(); + + const template = ``; + const fixture = createTestComponent(template); + const consoleWarnSpy = spyOn(console, 'warn'); + fixture.detectChanges(); + + expect(consoleWarnSpy.calls.count()).toBe(0); + }); + it('should set `src` using the image loader provided via the `IMAGE_LOADER` token to compose src URL', () => { const imageLoader = (config: ImageLoaderConfig) => `${IMG_BASE_URL}/${config.src}`; @@ -1526,6 +1576,16 @@ function setupTestingModule(config?: { }); } +// Same as above but explicitly doesn't provide a custom loader, +// so the noopImageLoader should be used. +function setUpModuleNoLoader() { + TestBed.configureTestingModule({ + declarations: [TestComponent], + imports: [CommonModule, NgOptimizedImage], + providers: [{provide: DOCUMENT, useValue: window.document}] + }); +} + function createTestComponent(template: string): ComponentFixture { return TestBed.overrideComponent(TestComponent, {set: {template: template}}) .createComponent(TestComponent);