Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(common): add NgOptimizedImage directive #47082

Closed
wants to merge 63 commits into from
Closed
Show file tree
Hide file tree
Changes from 57 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
33c28fc
feat(common): add Image directive skeleton (#45627)
AndrewKushnir Apr 15, 2022
693f30b
refactor(common): expose `NgImage` directive via a private API
AndrewKushnir Apr 20, 2022
cef1454
test(common): add a test app for the `NgImage` directive
AndrewKushnir Apr 20, 2022
9b03489
refactor(common): change name of directive from NgImage => NgOptimize…
kara Apr 22, 2022
88674b1
refactor(common): drop the `loader` input in favor of `IMAGE_LOADER` …
AndrewKushnir May 4, 2022
03d1959
refactor(common): rename `NgOptimizedImage` directive selector
AndrewKushnir May 10, 2022
8270b1d
feat(common): Add image lazy loading and fetchpriority
khempenius May 10, 2022
f4d0c0c
fix(common): detect `data:` and `blob:` inputs in `NgOptimizedImage` …
AndrewKushnir May 10, 2022
288ef43
refactor(common): mark experimental `NgOptimizedImage` directive as s…
AndrewKushnir May 8, 2022
76016f0
refactor(common): throw an error if `NgOptimizeImage` inputs change
AndrewKushnir May 4, 2022
a37164c
refactor(common): throw an error if `width` or `height` inputs are mi…
AndrewKushnir May 4, 2022
a83ab6e
refactor(common): move `NgOptimizedImage` logic that sets `src` to a …
AndrewKushnir May 11, 2022
efc87da
test(common): configure e2e testing infrastructure for `NgOptimizedIm…
AndrewKushnir May 12, 2022
5b4e1fd
feat(common): detect LCP images in `NgOptimizedImage` and assert if `…
AndrewKushnir May 8, 2022
4a62d67
test(common): add e2e tests for LCP check logic of the `NgOptimizedIm…
AndrewKushnir May 12, 2022
84af0ff
perf(common): monitor LCP only for images without `priority` attribute
AndrewKushnir May 13, 2022
b72eff4
fix(common): remove default for image width
kara May 11, 2022
072db8d
fixup! fix(common): remove default for image width
kara May 16, 2022
d1f29eb
test(common): fix relative URL issue in web tests
kara May 16, 2022
d578f39
test(common): reorg e2e folders for the `NgOptimizedImage` directive …
AndrewKushnir May 17, 2022
ca467e9
ci: temporarily publish snapshots for `image-directive` branch
devversion May 20, 2022
3c878c0
refactor(common): create an NgModule for the `NgOptimizedImage` direc…
AndrewKushnir May 24, 2022
2e82da6
fix(common): throw if srcset is used with rawSrc
kara May 11, 2022
8f7d00f
feat(common): support custom srcset attributes in NgOptimizedImage
kara May 11, 2022
c50f158
feat(common): add loading attr to NgOptimizedImage
khempenius May 25, 2022
939e3db
test: update error codes used in the `NgOptimizedImage` tests
AndrewKushnir May 30, 2022
bece596
feat(common): add built-in Imgix loader
kara May 27, 2022
009a4c9
feat(common): verify that priority images have preconnect links
AndrewKushnir May 13, 2022
8b00535
feat(common): provide an ability to exclude origins from preconnect c…
AndrewKushnir Jun 7, 2022
6e04e1b
fixup! feat(common): provide an ability to exclude origins from preco…
AndrewKushnir Jun 7, 2022
e42ae7b
refactor(common): move LCP image observer to a separate file (#46295)
AndrewKushnir Jun 8, 2022
11294a5
refactor(common): allow loaders to specify the ensurePreconnect option
pkozlowski-opensource Jun 8, 2022
da805c7
test(common): optimize image loader tests of the NgOptimizedImage dir…
AndrewKushnir Jun 7, 2022
03d0bde
fix(common): sanitize `rawSrc` and `rawSrcset` values in NgOptimizedI…
AndrewKushnir May 19, 2022
73f9b33
feat(common): add loaders for cloudinary & imagekit
khempenius Jun 7, 2022
b613afc
refactor(common): allow Cloudinary and ImageKit loaders to specify th…
AndrewKushnir Jun 9, 2022
2f15bf0
refactor(common): move loader util functions to a common location
AndrewKushnir Jun 9, 2022
d0f4238
feat(common): add cloudflare loader
khempenius Jun 9, 2022
fad57d0
refactor(common): remove code duplication in loaders
pkozlowski-opensource Jun 9, 2022
ab06725
feat(common): add warnings re: image distortion
khempenius Jun 10, 2022
ef98f0e
test(common): remove unneeded describe block
pkozlowski-opensource Jun 10, 2022
6ecd6a8
refactor(common): properly configure PRECONNECT_CHECK_BLOCKLIST
pkozlowski-opensource Jun 10, 2022
21080b5
refactor(common): add missing format error call in NgOptimizedImage
AndrewKushnir Jun 11, 2022
c3b7495
refactor(common): remove unnecessary toString conversions
pkozlowski-opensource Jun 10, 2022
2b77f6d
refactor(common): unify url error reporting in image loaders
pkozlowski-opensource Jun 10, 2022
ef4798d
Revert "fix(common): sanitize `rawSrc` and `rawSrcset` values in NgOp…
AndrewKushnir Jun 14, 2022
9a0b362
refactor(common): throw an error if an absolute URL is passed to Imag…
AndrewKushnir Jun 14, 2022
361d981
refactor(common): remove code duplication in image loaders
pkozlowski-opensource Jun 17, 2022
98e8950
refactor(common): simplify URL construction in image loaders
pkozlowski-opensource Jun 17, 2022
e9f43cc
feat(common): add a density cap for image srcsets
kara Jul 12, 2022
b9aa934
test(common): add parent injector to the `createEnvironmentInjector` …
AndrewKushnir Jul 19, 2022
ab521aa
feat(common): explain why width/height is required
khempenius Jul 15, 2022
80a8a92
refactor(common): update error messages of NgOptimizedImage directive
AndrewKushnir Jul 22, 2022
cb0fe50
refactor(common): make NgOptimizedImage directive standalone
AndrewKushnir Jul 22, 2022
6174151
feat(common): define public API surface for NgOptimizedImage directive
AndrewKushnir Aug 2, 2022
860fee8
docs: add description and usage notes to the NgOptimizedImage directive
AndrewKushnir Aug 4, 2022
28522b2
refactor(common): use quotes instead of backticks in missing width/he…
AndrewKushnir Aug 9, 2022
30ed35f
fix(common): set bound width and height onto host element
kara Aug 11, 2022
0a9601b
refactor(common): clean up util.ts and preconnect_link_checker files
kara Aug 11, 2022
c3a33ad
refactor(common): various NgOptimizedImage directive updates
AndrewKushnir Aug 12, 2022
5d9ae1c
refactor(common): address review feedback for NgOptimizedImage
pkozlowski-opensource Aug 12, 2022
062a4f6
feat(common): warn if rendered size is much smaller than intrinsic
kara Aug 11, 2022
1555226
refactor(common): minor NgOptimizedImage directive updates
AndrewKushnir Aug 12, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ var_11: &only_release_branches
branches:
only:
- main
- image-directive
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
- /\d+\.\d+\.x/

# CircleCI orbs
Expand Down
22 changes: 21 additions & 1 deletion goldens/public-api/common/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,32 @@

// @public
export const enum RuntimeErrorCode {
// (undocumented)
INVALID_INPUT = 2952,
// (undocumented)
INVALID_LOADER_ARGUMENTS = 2959,
// (undocumented)
INVALID_PIPE_ARGUMENT = 2100,
// (undocumented)
INVALID_PRECONNECT_CHECK_BLOCKLIST = 2957,
// (undocumented)
LCP_IMG_MISSING_PRIORITY = 2955,
// (undocumented)
NG_FOR_MISSING_DIFFER = -2200,
// (undocumented)
PARENT_NG_SWITCH_NOT_FOUND = 2000
PARENT_NG_SWITCH_NOT_FOUND = 2000,
// (undocumented)
PRIORITY_IMG_MISSING_PRECONNECT_TAG = 2956,
// (undocumented)
REQUIRED_INPUT_MISSING = 2954,
// (undocumented)
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
// (undocumented)
UNEXPECTED_INPUT_CHANGE = 2953,
// (undocumented)
UNEXPECTED_SRC_ATTR = 2950,
// (undocumented)
UNEXPECTED_SRCSET_ATTR = 2951
}

// (No @packageDocumentation comment for this package)
Expand Down
68 changes: 68 additions & 0 deletions goldens/public-api/common/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import { NgModuleFactory } from '@angular/core';
import { Observable } from 'rxjs';
import { OnChanges } from '@angular/core';
import { OnDestroy } from '@angular/core';
import { OnInit } from '@angular/core';
import { PipeTransform } from '@angular/core';
import { Provider } from '@angular/core';
import { Renderer2 } from '@angular/core';
import { SimpleChanges } from '@angular/core';
import { Subscribable } from 'rxjs';
Expand Down Expand Up @@ -256,6 +258,20 @@ export class I18nSelectPipe implements PipeTransform {
static ɵpipe: i0.ɵɵPipeDeclaration<I18nSelectPipe, "i18nSelect", true>;
}

// @public
export const IMAGE_LOADER: InjectionToken<ImageLoader>;

// @public
export type ImageLoader = (config: ImageLoaderConfig) => string;

// @public
export interface ImageLoaderConfig {
// (undocumented)
src: string;
// (undocumented)
width?: number;
}

// @public
export function isPlatformBrowser(platformId: Object): boolean;

Expand Down Expand Up @@ -517,6 +533,35 @@ export abstract class NgLocalization {
static ɵprov: i0.ɵɵInjectableDeclaration<NgLocalization>;
}

// @public
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
constructor(imageLoader: ImageLoader, renderer: Renderer2, imgElement: ElementRef, injector: Injector);
set height(value: string | number | undefined);
// (undocumented)
get height(): number | undefined;
loading?: string;
// (undocumented)
ngOnChanges(changes: SimpleChanges): void;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
set priority(value: string | boolean | undefined);
// (undocumented)
get priority(): boolean;
rawSrc: string;
rawSrcset: string;
// (undocumented)
srcset?: string;
set width(value: string | number | undefined);
// (undocumented)
get width(): number | undefined;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[rawSrc]", never, { "rawSrc": "rawSrc"; "rawSrcset": "rawSrcset"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "src": "src"; "srcset": "srcset"; }, {}, never, never, true>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<NgOptimizedImage, never>;
}

// @public
export class NgPlural {
constructor(_localization: NgLocalization);
Expand Down Expand Up @@ -743,6 +788,29 @@ interface PopStateEvent_2 {
}
export { PopStateEvent_2 as PopStateEvent }

// @public
export const PRECONNECT_CHECK_BLOCKLIST: InjectionToken<(string | string[])[]>;

// @public
export const provideCloudflareLoader: (path: string, options?: {
ensurePreconnect?: boolean | undefined;
}) => Provider[];

// @public
export const provideCloudinaryLoader: (path: string, options?: {
ensurePreconnect?: boolean | undefined;
}) => Provider[];

// @public
export const provideImageKitLoader: (path: string, options?: {
ensurePreconnect?: boolean | undefined;
}) => Provider[];

// @public
export const provideImgixLoader: (path: string, options?: {
ensurePreconnect?: boolean | undefined;
}) => 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 @@ -27,3 +27,4 @@ export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPL
export {VERSION} from './version';
export {ViewportScroller, NullViewportScroller as ɵNullViewportScroller} from './viewport_scroller';
export {XhrFactory} from './xhr';
export * from './directives/ng_optimized_image';
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
25 changes: 25 additions & 0 deletions packages/common/src/directives/ng_optimized_image/asserts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @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 {ɵRuntimeError as RuntimeError} from '@angular/core';

import {RuntimeErrorCode} from '../../errors';

/**
* Asserts whether an ngDevMode is enabled and throws an error if it's not the case.
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
* This assert can be used to make sure that there is no dev-mode code invoked in
* the prod mode accidentally.
*/
export function assertDevMode(check: string) {
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
if (!ngDevMode) {
throw new RuntimeError(
RuntimeErrorCode.UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE,
`Unexpected invocation of the ${check} in the prod mode. ` +
`Please make sure that the prod mode is enabled for production builds.`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @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 {createImageLoader, ImageLoaderConfig} from './image_loader';

pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
/**
* Function that generates a built-in ImageLoader for Cloudflare Image Resizing
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
* and turns it into an Angular provider. Note: Cloudflare has multiple image
* products - this provider is specifically for Cloudflare Image Resizing;
* it will not work with Cloudflare Images or Cloudflare Polish.
*
* @param path Your domain name
* e.g. https://mysite.com
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
* @returns Provider that provides an ImageLoader function
*
* @publicApi
* @developerPreview
*/
export const provideCloudflareLoader = createImageLoader(
createCloudflareURL,
ngDevMode ? ['https://<ZONE>/cdn-cgi/image/<OPTIONS>/<SOURCE-IMAGE>'] : undefined);
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved

function createCloudflareURL(path: string, config: ImageLoaderConfig) {
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
let params = `format=auto`;
if (config.width) {
params += `,width=${config.width}`;
}
return `${path}/cdn-cgi/image/${params}/${config.src}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* @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 {createImageLoader, ImageLoaderConfig} from './image_loader';

/**
* Function that generates a built-in ImageLoader for Cloudinary
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
* and turns it into an Angular provider.
*
* @param path Base URL of your Cloudinary images
* This URL should match one of the following formats:
* https://res.cloudinary.com/mysite
* https://mysite.cloudinary.com
* https://subdomain.mysite.com
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
* @param options An object that allows to provide extra configuration:
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
* should verify that there is a corresponding `<link rel="preconnect">`
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
* present in the document's `<head>`.
* @returns Set of providers to configure the Cloudinary loader.
*
* @publicApi
* @developerPreview
*/
export const provideCloudinaryLoader = createImageLoader(
createCloudinaryURL,
ngDevMode ?
[
'https://res.cloudinary.com/mysite', 'https://mysite.cloudinary.com',
'https://subdomain.mysite.com'
] :
undefined);

function createCloudinaryURL(path: string, config: ImageLoaderConfig) {
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
// Example of a Cloudinary image URL:
// https://res.cloudinary.com/mysite/image/upload/c_scale,f_auto,q_auto,w_600/marketing/tile-topics-m.png
let params = `f_auto,q_auto`; // sets image format and quality to "auto"
if (config.width) {
params += `,w_${config.width}`;
}
return `${path}/image/upload/${params}/${config.src}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* @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, Provider, ɵRuntimeError as RuntimeError} from '@angular/core';

import {RuntimeErrorCode} from '../../../errors';
import {PRECONNECT_CHECK_BLOCKLIST} from '../preconnect_link_checker';
import {isAbsoluteURL, isValidPath, normalizePath, normalizeSrc} from '../util';

/**
* Config options recognized by the image loader function.
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
*
* @publicApi
* @developerPreview
*/
export interface ImageLoaderConfig {
// Name of the image to be added to the image request URL
src: string;
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
// Width of the requested image (to be used when generating srcset)
width?: number;
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Represents an image loader function.
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
*
* @publicApi
* @developerPreview
*/
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.
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
*/
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.
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
*
* @publicApi
* @developerPreview
*/
export const IMAGE_LOADER = new InjectionToken<ImageLoader>('ImageLoader', {
providedIn: 'root',
factory: () => noopImageLoader,
});

export function createImageLoader(
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
buildUrlFn: (path: string, config: ImageLoaderConfig) => string, exampleUrls?: string[]) {
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved
return function provideImageLoader(
path: string, options: {ensurePreconnect?: boolean} = {ensurePreconnect: true}) {
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
if (ngDevMode && !isValidPath(path)) {
throwInvalidPathError(path, exampleUrls || []);
}
path = normalizePath(path);
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved

const loaderFn = (config: ImageLoaderConfig) => {
if (ngDevMode && isAbsoluteURL(config.src)) {
throwUnexpectedAbsoluteUrlError(path, config.src);
}
AndrewKushnir marked this conversation as resolved.
Show resolved Hide resolved

return buildUrlFn(path, {...config, src: normalizeSrc(config.src)});
};
const providers: Provider[] = [{provide: IMAGE_LOADER, useValue: loaderFn}];

if (ngDevMode && options.ensurePreconnect === false) {
providers.push({provide: PRECONNECT_CHECK_BLOCKLIST, useValue: [path], multi: true});
}

return providers;
};
}

function throwInvalidPathError(path: unknown, exampleUrls: string[]): never {
const exampleUrlsMsg = exampleUrls.join(' or ');
throw new RuntimeError(
RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
`Image loader has detected an invalid path (\`${path}\`). ` +
pkozlowski-opensource marked this conversation as resolved.
Show resolved Hide resolved
`To fix this, supply a path using one of the following formats: ${exampleUrlsMsg}`);
}

function throwUnexpectedAbsoluteUrlError(path: string, url: string): never {
throw new RuntimeError(
RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
`Image loader has detected a \`<img>\` tag with an invalid \`rawSrc\` attribute: ${url}. ` +
`This image loader expects \`rawSrc\` to be a relative URL - ` +
`however the provided value is an absolute URL. ` +
`To fix this, provide \`rawSrc\` as a path relative to the base URL ` +
`configured for this loader (\`${path}\`).`);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* @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 {createImageLoader, ImageLoaderConfig} from './image_loader';

/**
* Function that generates a built-in ImageLoader for ImageKit
* and turns it into an Angular provider.
*
* @param path Base URL of your ImageKit images
* This URL should match one of the following formats:
* https://ik.imagekit.io/myaccount
* https://subdomain.mysite.com
* @param options An object that allows to provide extra configuration:
* - `ensurePreconnect`: boolean flag indicating whether the NgOptimizedImage directive
* should verify that there is a corresponding `<link rel="preconnect">`
* present in the document's `<head>`.
* @returns Set of providers to configure the ImageKit loader.
*
* @publicApi
* @developerPreview
*/
export const provideImageKitLoader = createImageLoader(
createImagekitURL,
ngDevMode ? ['https://ik.imagekit.io/mysite', 'https://subdomain.mysite.com'] : undefined);

export function createImagekitURL(path: string, config: ImageLoaderConfig) {
// Example of an ImageKit image URL:
// https://ik.imagekit.io/demo/tr:w-300,h-300/medium_cafe_B1iTdD0C.jpg
let params = `tr:q-auto`; // applies the "auto quality" transformation
if (config.width) {
params += `,w-${config.width}`;
}
return `${path}/${params}/${config.src}`;
}