feat(common): Backport NgOptimizedImage to v13

Backport the image optimization features from verion 15 to version 13
This commit is contained in:
Alex Castle
2023-03-20 02:07:37 -07:00
committed by Joey Perrott
parent 69a906479f
commit ae34dbca1b
51 changed files with 5350 additions and 5 deletions
+1
View File
@@ -340,6 +340,7 @@ groups:
'aio/content/guide/dependency-injection-navtree.md',
'aio/content/guide/dependency-injection-providers.md',
'aio/content/guide/lightweight-injection-tokens.md',
'aio/content/guide/image-directive.md',
'aio/content/guide/displaying-data.md',
'aio/content/examples/displaying-data/**',
'aio/content/images/guide/displaying-data/**',
+323
View File
@@ -0,0 +1,323 @@
<div class="alert is-important">
Notice: Angular 13.4.0 includes a backported version of the NgOptimizedImage directive present in later versions of Angular. This was implemented by the Chrome Aurora team, as a special project to make performance enhancements available to more applications built on Angular. This does not reflect a shift in Angular release procedure and does not indicate that any additional features will be backported to earlier Angular versions.
</div>
# Getting started with NgOptimizedImage
The `NgOptimizedImage` directive makes it easy to adopt performance best practices for loading images.
The directive ensures that the loading of the [Largest Contentful Paint (LCP)](http://web.dev/lcp) image is prioritized by:
* Automatically setting the `fetchpriority` attribute on the `<img>` tag
* Lazy loading other images by default
* Asserting that there is a corresponding preconnect link tag in the document head
* Automatically generating a `srcset` attribute
* Generating a [preload hint](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload) if app is using SSR
In addition to optimizing the loading of the LCP image, `NgOptimizedImage` enforces a number of image best practices, such as:
* Using [image CDN URLs to apply image optimizations](https://web.dev/image-cdns/#how-image-cdns-use-urls-to-indicate-optimization-options)
* Preventing layout shift by requiring `width` and `height`
* Warning if `width` or `height` have been set incorrectly
* Warning if the image will be visually distorted when rendered
## Getting Started
#### Step 1: Import NgOptimizedImageModule
<code-example format="typescript" language="typescript">
import { NgOptimizedImageModule } from '@angular/common'
</code-example>
#### Step 2: (Optional) Set up a Loader
An image loader is not **required** in order to use NgOptimizedImage, but using one with an image CDN enables powerful performance features, including automatic `srcset`s for your images.
A brief guide for setting up a loader can be found in the [Configuring an Image Loader](#configuring-an-image-loader-for-ngoptimizedimage) section at the end of this page.
#### Step 3: Enable the directive
To activate the `NgOptimizedImage` directive, replace your image's `src` attribute with `ngSrc`.
<code-example format="typescript" language="typescript">
&lt;img ngSrc="cat.jpg"&gt;
</code-example>
If you're using a [built-in third-party loader](#built-in-loaders), make sure to omit the base URL path from `src`, as that will be prepended automatically by the loader.
#### Step 4: Mark images as `priority`
Always mark the [LCP image](https://web.dev/lcp/#what-elements-are-considered) on your page as `priority` to prioritize its loading.
<code-example format="typescript" language="typescript">
&lt;img ngSrc="cat.jpg" width="400" height="200" priority&gt;
</code-example>
Marking an image as `priority` applies the following optimizations:
* Sets `fetchpriority=high` (read more about priority hints [here](https://web.dev/priority-hints))
* Sets `loading=eager` (read more about native lazy loading [here](https://web.dev/browser-level-image-lazy-loading))
* Automatically generates a [preload link element](https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload) if [rendering on the server](/guide/universal).
Angular displays a warning during development if the LCP element is an image that does not have the `priority` attribute. A page’s LCP element can vary based on a number of factors - such as the dimensions of a user's screen, so a page may have multiple images that should be marked `priority`. See [CSS for Web Vitals](https://web.dev/css-web-vitals/#images-and-largest-contentful-paint-lcp) for more details.
#### Step 5: Include Height and Width
In order to prevent [image-related layout shifts](https://web.dev/css-web-vitals/#images-and-layout-shifts), NgOptimizedImage requires that you specify a height and width for your image, as follows:
<code-example format="typescript" language="typescript">
&lt;img ngSrc="cat.jpg" width="400" height="200"&gt;
</code-example>
For **responsive images** (images which you've styled to grow and shrink relative to the viewport), the `width` and `height` attributes should be the instrinsic size of the image file.
For **fixed size images**, the `width` and `height` attributes should reflect the desired rendered size of the image. The aspect ratio of these attributes should always match the intrinsic aspect ratio of the image.
Note: If you don't know the size of your images, consider using "fill mode" to inherit the size of the parent container, as described below:
### Using `fill` mode
In cases where you want to have an image fill a containing element, you can use the `fill` attribute. This is often useful when you want to achieve a "background image" behavior. It can also be helpful when you don't know the exact width and height of your image, but you do have a parent container with a known size that you'd like to fit your image into (see "object-fit" below).
When you add the `fill` attribute to your image, you do not need and should not include a `width` and `height`, as in this example:
<code-example format="typescript" language="typescript">
&lt;img ngSrc="cat.jpg" fill&gt;
</code-example>
You can use the [object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) CSS property to change how the image will fill its container. If you style your image with `object-fit: "contain"`, the image will maintain its aspect ratio and be "letterboxed" to fit the element. If you set `object-fit: "cover"`, the element will retain its aspect ratio, fully fill the element, and some content may be "cropped" off.
See visual examples of the above at the [MDN object-fit documentation.](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit)
You can also style your image with the [object-position property](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) to adjust its position within its containing element.
**Important note:** For the "fill" image to render properly, its parent element **must** be styled with `position: "relative"`, `position: "fixed"`, or `position: "absolute"`.
### Adjusting image styling
Depending on the image's styling, adding `width` and `height` attributes may cause the image to render differently. `NgOptimizedImage` warns you if your image styling renders the image at a distorted aspect ratio.
You can typically fix this by adding `height: auto` or `width: auto` to your image styles. For more information, see the [web.dev article on the `<img>` tag](https://web.dev/patterns/web-vitals-patterns/images/img-tag).
If the `height` and `width` attribute on the image are preventing you from sizing the image the way you want with CSS, consider using "fill" mode instead, and styling the image's parent element.
## Performance Features
NgOptimizedImage includes a number of features designed to improve loading performance in your app. These features are described in this section.
### Add resource hints
You can add a [`preconnect` resource hint](https://web.dev/preconnect-and-dns-prefetch) for your image origin to ensure that the LCP image loads as quickly as possible. Always put resource hints in the `<head>` of the document.
<code-example format="html" language="html">
&lt;link rel="preconnect" href="https://my.cdn.origin" /&gt;
</code-example>
By default, if you use a loader for a third-party image service, the `NgOptimizedImage` directive will warn during development if it detects that there is no `preconnect` resource hint for the origin that serves the LCP image.
To disable these warnings, inject the `PRECONNECT_CHECK_BLOCKLIST` token:
<code-example format="typescript" language="typescript">
providers: [
{provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://your-domain.com'}
],
</code-example>
### Request images at the correct size with automatic `srcset`
Defining a [`srcset` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset) ensures that the browser requests an image at the right size for your user's viewport, so it doesn't waste time downloading an image that's too large. `NgOptimizedImage` generates an appropriate `srcset` for the image, based on the presence and value of the [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes) on the image tag.
#### Fixed-size images
If your image should be "fixed" in size (i.e. the same size across devices, except for [pixel density](https://web.dev/codelab-density-descriptors/)), there is no need to set a `sizes` attribute. A `srcset` can be generated automatically from the image's width and height attributes with no further input required.
Example srcset generated: `<img ... srcset="image-400w.jpg 1x, image-800w.jpg 2x">`
#### Responsive images
If your image should be responsive (i.e. grow and shrink according to viewport size), then you will need to define a [`sizes` attribute](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes) to generate the `srcset`.
If you haven't used `sizes` before, a good place to start is to set it based on viewport width. For example, if your CSS causes the image to fill 100% of viewport width, set `sizes` to `100vw` and the browser will select the image in the `srcset` that is closest to the viewport width (after accounting for pixel density). If your image is only likely to take up half the screen (ex: in a sidebar), set `sizes` to `50vw` to ensure the browser selects a smaller image. And so on.
If you find that the above does not cover your desired image behavior, see the documentation on [advanced sizes values](#advanced-sizes-values).
By default, the responsive breakpoints are:
`[16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840]`
If you would like to customize these breakpoints, you can do so using the `IMAGE_CONFIG` provider:
<code-example format="typescript" language="typescript">
providers: [
{
provide: IMAGE_CONFIG,
useValue: {
breakpoints: [16, 48, 96, 128, 384, 640, 750, 828, 1080, 1200, 1920]
}
},
],
</code-example>
If you would like to manually define a `srcset` attribute, you can provide your own using the `ngSrcset` attribute:
<code-example format="html" language="html">
&lt;img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w"&gt;
</code-example>
If the `ngSrcset` attribute is present, `NgOptimizedImage` generates and sets the `srcset` based on the sizes included. Do not include image file names in `ngSrcset` - the directive infers this information from `ngSrc`. The directive supports both width descriptors (e.g. `100w`) and density descriptors (e.g. `1x`).
<code-example format="html" language="html">
&lt;img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w" sizes="50vw"&gt;
</code-example>
### Disabling automatic srcset generation
To disable srcset generation for a single image, you can add the `disableOptimizedSrcset` attribute on the image:
<code-example format="html" language="html">
&lt;img ngSrc="about.jpg" disableOptimizedSrcset&gt;
</code-example>
### Disabling image lazy loading
By default, `NgOptimizedImage` sets `loading=lazy` for all images that are not marked `priority`. You can disable this behavior for non-priority images by setting the `loading` attribute. This attribute accepts values: `eager`, `auto`, and `lazy`. [See the documentation for the standard image `loading` attribute for details](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/loading#value).
<code-example format="html" language="html">
&lt;img ngSrc="cat.jpg" width="400" height="200" loading="eager"&gt;
</code-example>
### Advanced 'sizes' values
You may want to have images displayed at varying widths on differently-sized screens. A common example of this pattern is a grid- or column-based layout that renders a single column on mobile devices, and two columns on larger devices. You can capture this behavior in the `sizes` attribute, using a "media query" syntax, such as the following:
<code-example format="html" language="html">
&lt;img ngSrc="cat.jpg" width="400" height="200" sizes="(max-width: 768px) 100vw, 50vw"&gt;
</code-example>
The `sizes` attribute in the above example says "I expect this image to be 100 percent of the screen width on devices under 768px wide. Otherwise, I expect it to be 50 percent of the screen width.
For additional information about the `sizes` attribute, see [web.dev](https://web.dev/learn/design/responsive-images/#sizes) or [mdn](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes).
## Configuring an image loader for `NgOptimizedImage`
A "loader" is a function that generates an [image transformation URL](https://web.dev/image-cdns/#how-image-cdns-use-urls-to-indicate-optimization-options) for a given image file. When appropriate, `NgOptimizedImage` sets the size, format, and image quality transformations for an image.
`NgOptimizedImage` provides both a generic loader that applies no transformations, as well as loaders for various third-party image services. It also supports writing your own custom loader.
| Loader type| Behavior |
|:--- |:--- |
| Generic loader | The URL returned by the generic loader will always match the value of `src`. In other words, this loader applies no transformations. Sites that use Angular to serve images are the primary intended use case for this loader.|
| Loaders for third-party image services | The URL returned by the loaders for third-party image services will follow API conventions used by that particular image service. |
| Custom loaders | A custom loader's behavior is defined by its developer. You should use a custom loader if your image service isn't supported by the loaders that come preconfigured with `NgOptimizedImage`.|
Based on the image services commonly used with Angular applications, `NgOptimizedImage` provides loaders preconfigured to work with the following image services:
| Image Service | Angular API | Documentation |
|:--- |:--- |:--- |
| Cloudflare Image Resizing | `provideCloudflareLoader` | [Documentation](https://developers.cloudflare.com/images/image-resizing/) |
| Cloudinary | `provideCloudinaryLoader` | [Documentation](https://cloudinary.com/documentation/resizing_and_cropping) |
| ImageKit | `provideImageKitLoader` | [Documentation](https://docs.imagekit.io/) |
| Imgix | `provideImgixLoader` | [Documentation](https://docs.imgix.com/) |
To use the **generic loader** no additional code changes are necessary. This is the default behavior.
### Built-in Loaders
To use an existing loader for a **third-party image service**, add the provider factory for your chosen service to the `providers` array. In the example below, the Imgix loader is used:
<code-example format="typescript" language="typescript">
providers: [
provideImgixLoader('https://my.base.url/'),
],
</code-example>
The base URL for your image assets should be passed to the provider factory as an argument. For most sites, this base URL should match one of the following patterns:
* https://yoursite.yourcdn.com
* https://subdomain.yoursite.com
* https://subdomain.yourcdn.com/yoursite
You can learn more about the base URL structure in the docs of a corresponding CDN provider.
### Custom Loaders
To use a **custom loader**, provide your loader function as a value for the `IMAGE_LOADER` DI token. In the example below, the custom loader function returns a URL starting with `https://example.com` that includes `src` and `width` as URL parameters.
<code-example format="typescript" language="typescript">
providers: [
{
provide: IMAGE_LOADER,
useValue: (config: ImageLoaderConfig) => {
return `https://example.com/images?src=${config.src}&width=${config.width}`;
},
},
],
</code-example>
A loader function for the `NgOptimizedImage` directive takes an object with the `ImageLoaderConfig` type (from `@angular/common`) as its argument and returns the absolute URL of the image asset. The `ImageLoaderConfig` object contains the `src` property, and optional `width` and `loaderParams` properties.
Note: even though the `width` property may not always be present, a custom loader must use it to support requesting images at various widths in order for `ngSrcset` to work properly.
### The `loaderParams` Property
There is an additional attribute supported by the `NgOptimizedImage` directive, called `loaderParams`, which is specifically designed to support the use of custom loaders. The `loaderParams` attribute take an object with any properties as a value, and does not do anything on its own. The data in `loaderParams` is added to the `ImageLoaderConfig` object passed to your custom loader, and can be used to control the behavior of the loader.
A common use for `loaderParams` is controlling advanced image CDN features.
### Example custom loader
The following shows an example of a custom loader function. This example function concatenates `src` and `width`, and uses `loaderParams` to control a custom CDN feature for rounded corners:
<code-example format="typescript" language="typescript">
const myCustomLoader = (config: ImageLoaderConfig) => {
let url = `https://example.com/images/${config.src}?`;
let queryParams = [];
if (config.width) {
queryParams.push(`w=${config.width}`);
}
if (config.loaderParams?.roundedCorners) {
queryParams.push('mask=corners&corner-radius=5');
}
return url + queryParams.join('&');
};
</code-example>
Note that in the above example, we've invented the 'roundedCorners' property name to control a feature of our custom loader. We could then use this feature when creating an image, as follows:
<code-example format="html" language="html">
&lt;img ngSrc="profile.jpg" width="300" height="300" [loaderParams]="{roundedCorners: true}"&gt;
</code-example>
<!-- links -->
<!-- external links -->
<!--end links -->
@reviewed 2022-11-07
+5
View File
@@ -355,6 +355,11 @@
"title": "HTTP Client",
"tooltip": "Use HTTP to talk to a remote server."
},
{
"url": "guide/image-directive",
"title": "Image Directive",
"tooltip": "Performant images with the Angular image directive."
},
{
"title": "Testing",
"tooltip": "Testing your Angular apps.",
+29 -1
View File
@@ -6,10 +6,38 @@
// @public
export const enum RuntimeErrorCode {
// (undocumented)
INVALID_INPUT = 2952,
// (undocumented)
INVALID_LOADER_ARGUMENTS = 2959,
// (undocumented)
INVALID_PIPE_ARGUMENT = 2100,
// (undocumented)
PARENT_NG_SWITCH_NOT_FOUND = 2000
INVALID_PRECONNECT_CHECK_BLOCKLIST = 2957,
// (undocumented)
LCP_IMG_MISSING_PRIORITY = 2955,
// (undocumented)
MISSING_BUILTIN_LOADER = 2962,
// (undocumented)
MISSING_NECESSARY_LOADER = 2963,
// (undocumented)
OVERSIZED_IMAGE = 2960,
// (undocumented)
PARENT_NG_SWITCH_NOT_FOUND = 2000,
// (undocumented)
PRIORITY_IMG_MISSING_PRECONNECT_TAG = 2956,
// (undocumented)
REQUIRED_INPUT_MISSING = 2954,
// (undocumented)
TOO_MANY_PRELOADED_IMAGES = 2961,
// (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)
+87
View File
@@ -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';
@@ -254,6 +256,29 @@ export class I18nSelectPipe implements PipeTransform {
static ɵpipe: i0.ɵɵPipeDeclaration<I18nSelectPipe, "i18nSelect">;
}
// @public
export const IMAGE_CONFIG: InjectionToken<ImageConfig>;
// @public
export const IMAGE_LOADER: InjectionToken<ImageLoader>;
// @public
export type ImageConfig = {
breakpoints?: number[];
};
// @public
export type ImageLoader = (config: ImageLoaderConfig) => string;
// @public
export interface ImageLoaderConfig {
loaderParams?: {
[key: string]: any;
};
src: string;
width?: number;
}
// @public
export function isPlatformBrowser(platformId: Object): boolean;
@@ -509,6 +534,53 @@ export abstract class NgLocalization {
static ɵprov: i0.ɵɵInjectableDeclaration<NgLocalization>;
}
// @public
export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
constructor(imageLoader: ImageLoader, config: ImageConfig, renderer: Renderer2, elementRef: ElementRef, injector: Injector, platformId: string, preloadLinkChecker: PreloadLinkCreator);
set disableOptimizedSrcset(value: string | boolean | undefined);
// (undocumented)
get disableOptimizedSrcset(): boolean;
set fill(value: string | boolean | undefined);
// (undocumented)
get fill(): boolean;
set height(value: string | number | undefined);
// (undocumented)
get height(): number | undefined;
loaderParams?: {
[key: string]: any;
};
loading?: 'lazy' | 'eager' | 'auto';
// (undocumented)
ngOnChanges(changes: SimpleChanges): void;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
ngSrc: string;
ngSrcset: string;
set priority(value: string | boolean | undefined);
// (undocumented)
get priority(): boolean;
sizes?: string;
set width(value: string | number | undefined);
// (undocumented)
get width(): number | undefined;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc]", never, { "ngSrc": "ngSrc"; "ngSrcset": "ngSrcset"; "sizes": "sizes"; "width": "width"; "height": "height"; "loading": "loading"; "priority": "priority"; "loaderParams": "loaderParams"; "disableOptimizedSrcset": "disableOptimizedSrcset"; "fill": "fill"; "src": "src"; "srcset": "srcset"; }, {}, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<NgOptimizedImage, never>;
}
// @public @deprecated
export class NgOptimizedImageModule {
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<NgOptimizedImageModule, never>;
// (undocumented)
static ɵinj: i0.ɵɵInjectorDeclaration<NgOptimizedImageModule>;
// (undocumented)
static ɵmod: i0.ɵɵNgModuleDeclaration<NgOptimizedImageModule, [typeof NgOptimizedImage], never, [typeof NgOptimizedImage]>;
}
// @public
export class NgPlural {
constructor(_localization: NgLocalization);
@@ -732,6 +804,21 @@ interface PopStateEvent_2 {
}
export { PopStateEvent_2 as PopStateEvent }
// @public
export const PRECONNECT_CHECK_BLOCKLIST: InjectionToken<(string | string[])[]>;
// @public
export const provideCloudflareLoader: (path: string) => Provider[];
// @public
export const provideCloudinaryLoader: (path: string) => Provider[];
// @public
export const provideImageKitLoader: (path: string) => Provider[];
// @public
export const provideImgixLoader: (path: string) => Provider[];
// @public
export function registerLocaleData(data: any, localeId?: string | any, extraData?: any): void;
@@ -33,7 +33,7 @@
"cli-hello-world-lazy": {
"uncompressed": {
"runtime": 2835,
"main": 230780,
"main": 230267,
"polyfills": 37244,
"src_app_lazy_lazy_module_ts": 795
}
+1
View File
@@ -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 {IMAGE_CONFIG, ImageConfig, IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImage, NgOptimizedImageModule, PRECONNECT_CHECK_BLOCKLIST, provideCloudflareLoader, provideCloudinaryLoader, provideImageKitLoader, provideImgixLoader} from './directives/ng_optimized_image';
+5 -1
View File
@@ -7,10 +7,12 @@
*/
import {Provider} from '@angular/core';
import {NgClass} from './ng_class';
import {NgComponentOutlet} from './ng_component_outlet';
import {NgForOf, NgForOfContext} from './ng_for_of';
import {NgIf, NgIfContext} from './ng_if';
import {NgOptimizedImage, NgOptimizedImageModule} from './ng_optimized_image/ng_optimized_image';
import {NgPlural, NgPluralCase} from './ng_plural';
import {NgStyle} from './ng_style';
import {NgSwitch, NgSwitchCase, NgSwitchDefault} from './ng_switch';
@@ -23,13 +25,15 @@ export {
NgForOfContext,
NgIf,
NgIfContext,
NgOptimizedImage,
NgOptimizedImageModule,
NgPlural,
NgPluralCase,
NgStyle,
NgSwitch,
NgSwitchCase,
NgSwitchDefault,
NgTemplateOutlet,
NgTemplateOutlet
};
@@ -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 that the application is in development mode. Throws an error if the application is in
* production mode. This assert can be used to make sure that there is no dev-mode code invoked in
* the prod mode accidentally.
*/
export function assertDevMode(checkName: string) {
if (!ngDevMode) {
throw new RuntimeError(
RuntimeErrorCode.UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE,
`Unexpected invocation of the ${checkName} in the prod mode. ` +
`Please make sure that the prod mode is enabled for production builds.`);
}
}
@@ -0,0 +1,14 @@
/**
* @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
*/
// Assembles directive details string, useful for error messages.
export function imgDirectiveDetails(ngSrc: string, includeNgSrc = true) {
const ngSrcInfo =
includeNgSrc ? `(activated on an <img> element with the \`ngSrc="${ngSrc}"\`) ` : '';
return `The NgOptimizedImage directive ${ngSrcInfo}has detected that`;
}
@@ -0,0 +1,35 @@
/**
* @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 an ImageLoader for [Cloudflare Image
* Resizing](https://developers.cloudflare.com/images/image-resizing/) 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
* @returns Provider that provides an ImageLoader function
*
* @publicApi
*/
export const provideCloudflareLoader = createImageLoader(
createCloudflareUrl,
ngDevMode ? ['https://<ZONE>/cdn-cgi/image/<OPTIONS>/<SOURCE-IMAGE>'] : undefined);
// Exported for testing purposes in backport only. Not to be accessed except in unit tests.
export function createCloudflareUrl(path: string, config: ImageLoaderConfig) {
let params = `format=auto`;
if (config.width) {
params += `,width=${config.width}`;
}
// Cloudflare image URLs format:
// https://developers.cloudflare.com/images/image-resizing/url-format/
return `${path}/cdn-cgi/image/${params}/${config.src}`;
}
@@ -0,0 +1,59 @@
/**
* @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, 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.
*
* @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
* @returns Set of providers to configure the Cloudinary loader.
*
* @publicApi
*/
export const provideCloudinaryLoader = createImageLoader(
createCloudinaryUrl,
ngDevMode ?
[
'https://res.cloudinary.com/mysite', 'https://mysite.cloudinary.com',
'https://subdomain.mysite.com'
] :
undefined);
// Exported for testing purposes in backport only. Not to be accessed except in unit tests.
export function createCloudinaryUrl(path: string, config: ImageLoaderConfig) {
// Cloudinary image URLformat:
// https://cloudinary.com/documentation/image_transformations#transformation_url_structure
// 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}`;
}
@@ -0,0 +1,130 @@
/**
* @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 {isAbsoluteUrl, isValidPath, normalizePath, normalizeSrc} from '../url';
/**
* Config options recognized by the image loader function.
*
* @see `ImageLoader`
* @see `NgOptimizedImage`
* @publicApi
*/
export interface ImageLoaderConfig {
/**
* Image file name to be added to the image request URL.
*/
src: string;
/**
* Width of the requested image (to be used when generating srcset).
*/
width?: number;
/**
* Additional user-provided parameters for use by the ImageLoader.
*/
loaderParams?: {[key: string]: any;};
}
/**
* Represents an image loader function. Image loader functions are used by the
* NgOptimizedImage directive to produce full image URL based on the image name and its width.
*
* @publicApi
*/
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.
*
* @see `ImageLoader`
* @see `NgOptimizedImage`
*/
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.
*
* @see `ImageLoader`
* @see `NgOptimizedImage`
* @publicApi
*/
export const IMAGE_LOADER = new InjectionToken<ImageLoader>('ImageLoader', {
providedIn: 'root',
factory: () => noopImageLoader,
});
/**
* Internal helper function that makes it easier to introduce custom image loaders for the
* `NgOptimizedImage` directive. It is enough to specify a URL builder function to obtain full DI
* configuration for a given loader: a DI token corresponding to the actual loader function, plus DI
* tokens managing preconnect check functionality.
* @param buildUrlFn a function returning a full URL based on loader's configuration
* @param exampleUrls example of full URLs for a given loader (used in error messages)
* @returns a set of DI providers corresponding to the configured image loader
*/
export function createImageLoader(
buildUrlFn: (path: string, config: ImageLoaderConfig) => string, exampleUrls?: string[]) {
return function provideImageLoader(path: string) {
if (!isValidPath(path)) {
throwInvalidPathError(path, exampleUrls || []);
}
// The trailing / is stripped (if provided) to make URL construction (concatenation) easier in
// the individual loader functions.
path = normalizePath(path);
const loaderFn = (config: ImageLoaderConfig) => {
if (isAbsoluteUrl(config.src)) {
// Image loader functions expect an image file name (e.g. `my-image.png`)
// or a relative path + a file name (e.g. `/a/b/c/my-image.png`) as an input,
// so the final absolute URL can be constructed.
// When an absolute URL is provided instead - the loader can not
// build a final URL, thus the error is thrown to indicate that.
throwUnexpectedAbsoluteUrlError(path, config.src);
}
return buildUrlFn(path, {...config, src: normalizeSrc(config.src)});
};
const providers: Provider[] = [{provide: IMAGE_LOADER, useValue: loaderFn}];
return providers;
};
}
function throwInvalidPathError(path: unknown, exampleUrls: string[]): never {
throw new RuntimeError(
RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
ngDevMode &&
`Image loader has detected an invalid path (\`${path}\`). ` +
`To fix this, supply a path using one of the following formats: ${
exampleUrls.join(' or ')}`);
}
function throwUnexpectedAbsoluteUrlError(path: string, url: string): never {
throw new RuntimeError(
RuntimeErrorCode.INVALID_LOADER_ARGUMENTS,
ngDevMode &&
`Image loader has detected a \`<img>\` tag with an invalid \`ngSrc\` attribute: ${
url}. ` +
`This image loader expects \`ngSrc\` to be a relative URL - ` +
`however the provided value is an absolute URL. ` +
`To fix this, provide \`ngSrc\` as a path relative to the base URL ` +
`configured for this loader (\`${path}\`).`);
}
@@ -0,0 +1,50 @@
/**
* @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, 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.
*
* @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
* @returns Set of providers to configure the ImageKit loader.
*
* @publicApi
*/
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}`;
}
@@ -0,0 +1,48 @@
/**
* @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, 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.
*
* @param path path to the desired Imgix origin,
* e.g. https://somepath.imgix.net or https://images.mysite.com
* @returns Set of providers to configure the Imgix loader.
*
* @publicApi
*/
export const provideImgixLoader =
createImageLoader(createImgixUrl, ngDevMode ? ['https://somepath.imgix.net/'] : undefined);
// Exported for testing purposes in backport only. Not to be accessed except in unit tests.
export function createImgixUrl(path: string, config: ImageLoaderConfig) {
const url = new URL(`${path}/${config.src}`);
// This setting ensures the smallest allowable format is set.
url.searchParams.set('auto', 'format');
if (config.width) {
url.searchParams.set('w', config.width.toString());
}
return url.href;
}
@@ -0,0 +1,16 @@
/**
* @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
*/
// These exports represent the set of symbols exposed as a public API.
export {provideCloudflareLoader} from './image_loaders/cloudflare_loader';
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 {IMAGE_CONFIG, ImageConfig, NgOptimizedImage, NgOptimizedImageModule} from './ng_optimized_image';
export {PRECONNECT_CHECK_BLOCKLIST} from './preconnect_link_checker';
@@ -0,0 +1,104 @@
/**
* @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 {inject, Injectable, OnDestroy, ɵformatRuntimeError as formatRuntimeError} from '@angular/core';
import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';
import {assertDevMode} from './asserts';
import {imgDirectiveDetails} from './error_helper';
import {getUrl} from './url';
/**
* Observer that detects whether an image with `NgOptimizedImage`
* is treated as a Largest Contentful Paint (LCP) element. If so,
* asserts that the image has the `priority` attribute.
*
* Note: this is a dev-mode only class and it does not appear in prod bundles,
* thus there is no `ngDevMode` use in the code.
*
* Based on https://web.dev/lcp/#measure-lcp-in-javascript.
*/
@Injectable({providedIn: 'root'})
export class LCPImageObserver implements OnDestroy {
// Map of full image URLs -> original `ngSrc` values.
private images = new Map<string, string>();
// Keep track of images for which `console.warn` was produced.
private alreadyWarned = new Set<string>();
private window: Window|null = null;
private observer: PerformanceObserver|null = null;
constructor() {
assertDevMode('LCP checker');
const win = inject(DOCUMENT).defaultView;
if (typeof win !== 'undefined' && typeof PerformanceObserver !== 'undefined') {
this.window = win;
this.observer = this.initPerformanceObserver();
}
}
/**
* Inits PerformanceObserver and subscribes to LCP events.
* Based on https://web.dev/lcp/#measure-lcp-in-javascript
*/
private initPerformanceObserver(): PerformanceObserver {
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
if (entries.length === 0) return;
// We use the latest entry produced by the `PerformanceObserver` as the best
// signal on which element is actually an LCP one. As an example, the first image to load on
// a page, by virtue of being the only thing on the page so far, is often a LCP candidate
// and gets reported by PerformanceObserver, but isn't necessarily the LCP element.
const lcpElement = entries[entries.length - 1];
// Cast to `any` due to missing `element` on the `LargestContentfulPaint` type of entry.
// See https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint
const imgSrc = (lcpElement as any).element?.src ?? '';
// Exclude `data:` and `blob:` URLs, since they are not supported by the directive.
if (imgSrc.startsWith('data:') || imgSrc.startsWith('blob:')) return;
const imgNgSrc = this.images.get(imgSrc);
if (imgNgSrc && !this.alreadyWarned.has(imgSrc)) {
this.alreadyWarned.add(imgSrc);
logMissingPriorityWarning(imgSrc);
}
});
observer.observe({type: 'largest-contentful-paint', buffered: true});
return observer;
}
registerImage(rewrittenSrc: string, originalNgSrc: string) {
if (!this.observer) return;
this.images.set(getUrl(rewrittenSrc, this.window!).href, originalNgSrc);
}
unregisterImage(rewrittenSrc: string) {
if (!this.observer) return;
this.images.delete(getUrl(rewrittenSrc, this.window!).href);
}
ngOnDestroy() {
if (!this.observer) return;
this.observer.disconnect();
this.images.clear();
this.alreadyWarned.clear();
}
}
function logMissingPriorityWarning(ngSrc: string) {
const directiveDetails = imgDirectiveDetails(ngSrc);
console.warn(formatRuntimeError(
RuntimeErrorCode.LCP_IMG_MISSING_PRIORITY,
`${directiveDetails} this image is the Largest Contentful Paint (LCP) ` +
`element but was not marked "priority". This image should be marked ` +
`"priority" in order to prioritize its loading. ` +
`To fix this, add the "priority" attribute.`));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
/**
* @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 {inject, Injectable, InjectFlags, InjectionToken, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';
import {assertDevMode} from './asserts';
import {imgDirectiveDetails} from './error_helper';
import {extractHostname, getUrl} from './url';
// Set of origins that are always excluded from the preconnect checks.
const INTERNAL_PRECONNECT_CHECK_BLOCKLIST = new Set(['localhost', '127.0.0.1', '0.0.0.0']);
/**
* Injection token to configure which origins should be excluded
* from the preconnect checks. It can either be a single string or an array of strings
* to represent a group of origins, for example:
*
* ```typescript
* {provide: PRECONNECT_CHECK_BLOCKLIST, useValue: 'https://your-domain.com'}
* ```
*
* or:
*
* ```typescript
* {provide: PRECONNECT_CHECK_BLOCKLIST,
* useValue: ['https://your-domain-1.com', 'https://your-domain-2.com']}
* ```
*
* @publicApi
*/
export const PRECONNECT_CHECK_BLOCKLIST =
new InjectionToken<Array<string|string[]>>('PRECONNECT_CHECK_BLOCKLIST');
/**
* Contains the logic to detect whether an image, marked with the "priority" attribute
* has a corresponding `<link rel="preconnect">` tag in the `document.head`.
*
* Note: this is a dev-mode only class, which should not appear in prod bundles,
* thus there is no `ngDevMode` use in the code.
*/
@Injectable({providedIn: 'root'})
export class PreconnectLinkChecker {
private document;
/**
* Set of <link rel="preconnect"> tags found on this page.
* The `null` value indicates that there was no DOM query operation performed.
*/
private preconnectLinks: Set<string>|null = null;
/*
* Keep track of all already seen origin URLs to avoid repeating the same check.
*/
private alreadySeen = new Set<string>();
private window: Window|null = null;
private blocklist = new Set<string>(INTERNAL_PRECONNECT_CHECK_BLOCKLIST);
constructor() {
this.document = inject(DOCUMENT);
assertDevMode('preconnect link checker');
const win = this.document.defaultView;
if (typeof win !== 'undefined') {
this.window = win;
}
const blocklist = inject(PRECONNECT_CHECK_BLOCKLIST, InjectFlags.Optional);
if (blocklist) {
this.populateBlocklist(blocklist);
}
}
private populateBlocklist(origins: Array<string|string[]>|string) {
if (Array.isArray(origins)) {
deepForEach(origins, origin => {
this.blocklist.add(extractHostname(origin));
});
} else {
this.blocklist.add(extractHostname(origins));
}
}
/**
* Checks that a preconnect resource hint exists in the head for the
* given src.
*
* @param rewrittenSrc src formatted with loader
* @param originalNgSrc ngSrc value
*/
assertPreconnect(rewrittenSrc: string, originalNgSrc: string): void {
if (!this.window) return;
const imgUrl = getUrl(rewrittenSrc, this.window);
if (this.blocklist.has(imgUrl.hostname) || this.alreadySeen.has(imgUrl.origin)) return;
// Register this origin as seen, so we don't check it again later.
this.alreadySeen.add(imgUrl.origin);
if (!this.preconnectLinks) {
// Note: we query for preconnect links only *once* and cache the results
// for the entire lifespan of an application, since it's unlikely that the
// list would change frequently. This allows to make sure there are no
// performance implications of making extra DOM lookups for each image.
this.preconnectLinks = this.queryPreconnectLinks();
}
if (!this.preconnectLinks.has(imgUrl.origin)) {
console.warn(formatRuntimeError(
RuntimeErrorCode.PRIORITY_IMG_MISSING_PRECONNECT_TAG,
`${imgDirectiveDetails(originalNgSrc)} there is no preconnect tag present for this ` +
`image. Preconnecting to the origin(s) that serve priority images ensures that these ` +
`images are delivered as soon as possible. To fix this, please add the following ` +
`element into the <head> of the document:\n` +
` <link rel="preconnect" href="${imgUrl.origin}">`));
}
}
private queryPreconnectLinks(): Set<string> {
const preconnectUrls = new Set<string>();
const selector = 'link[rel=preconnect]';
const links: HTMLLinkElement[] = Array.from(this.document.querySelectorAll(selector));
for (let link of links) {
const url = getUrl(link.href, this.window!);
preconnectUrls.add(url.origin);
}
return preconnectUrls;
}
ngOnDestroy() {
this.preconnectLinks?.clear();
this.alreadySeen.clear();
}
}
/**
* Invokes a callback for each element in the array. Also invokes a callback
* recursively for each nested array.
*/
function deepForEach<T>(input: (T|any[])[], fn: (value: T) => void): void {
for (let value of input) {
Array.isArray(value) ? deepForEach(value, fn) : fn(value);
}
}
@@ -0,0 +1,85 @@
/**
* @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 {inject, Injectable, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core';
import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';
import {DEFAULT_PRELOADED_IMAGES_LIMIT, PRELOADED_IMAGES} from './tokens';
/**
* @description Contains the logic needed to track and add preload link tags to the `<head>` tag. It
* will also track what images have already had preload link tags added so as to not duplicate link
* tags.
*
* In dev mode this service will validate that the number of preloaded images does not exceed the
* configured default preloaded images limit: {@link DEFAULT_PRELOADED_IMAGES_LIMIT}.
*/
@Injectable({providedIn: 'root'})
export class PreloadLinkCreator {
private readonly preloadedImages;
private readonly document;
constructor() {
this.preloadedImages = inject(PRELOADED_IMAGES);
this.document = inject(DOCUMENT);
}
/**
* @description Add a preload `<link>` to the `<head>` of the `index.html` that is served from the
* server while using Angular Universal and SSR to kick off image loads for high priority images.
*
* The `sizes` (passed in from the user) and `srcset` (parsed and formatted from `ngSrcset`)
* properties used to set the corresponding attributes, `imagesizes` and `imagesrcset`
* respectively, on the preload `<link>` tag so that the correctly sized image is preloaded from
* the CDN.
*
* {@link https://web.dev/preload-responsive-images/#imagesrcset-and-imagesizes}
*
* @param renderer The `Renderer2` passed in from the directive
* @param src The original src of the image that is set on the `ngSrc` input.
* @param srcset The parsed and formatted srcset created from the `ngSrcset` input
* @param sizes The value of the `sizes` attribute passed in to the `<img>` tag
*/
createPreloadLinkTag(renderer: Renderer2, src: string, srcset?: string, sizes?: string): void {
if (ngDevMode) {
if (this.preloadedImages.size >= DEFAULT_PRELOADED_IMAGES_LIMIT) {
throw new RuntimeError(
RuntimeErrorCode.TOO_MANY_PRELOADED_IMAGES,
ngDevMode &&
`The \`NgOptimizedImage\` directive has detected that more than ` +
`${DEFAULT_PRELOADED_IMAGES_LIMIT} images were marked as priority. ` +
`This might negatively affect an overall performance of the page. ` +
`To fix this, remove the "priority" attribute from images with less priority.`);
}
}
if (this.preloadedImages.has(src)) {
return;
}
this.preloadedImages.add(src);
const preload = renderer.createElement('link');
renderer.setAttribute(preload, 'as', 'image');
renderer.setAttribute(preload, 'href', src);
renderer.setAttribute(preload, 'rel', 'preload');
renderer.setAttribute(preload, 'fetchpriority', 'high');
if (sizes) {
renderer.setAttribute(preload, 'imageSizes', sizes);
}
if (srcset) {
renderer.setAttribute(preload, 'imageSrcset', srcset);
}
renderer.appendChild(this.document.head, preload);
}
}
@@ -0,0 +1,27 @@
/**
* @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} from '@angular/core';
/**
* In SSR scenarios, a preload `<link>` element is generated for priority images.
* Having a large number of preload tags may negatively affect the performance,
* so we warn developers (by throwing an error) if the number of preloaded images
* is above a certain threshold. This const specifies this threshold.
*/
export const DEFAULT_PRELOADED_IMAGES_LIMIT = 5;
/**
* Helps to keep track of priority images that already have a corresponding
* preload tag (to avoid generating multiple preload tags with the same URL).
*
* This Set tracks the original src passed into the `ngSrc` input not the src after it has been
* run through the specified `IMAGE_LOADER`.
*/
export const PRELOADED_IMAGES = new InjectionToken<Set<string>>(
'NG_OPTIMIZED_PRELOADED_IMAGES', {providedIn: 'root', factory: () => new Set<string>()});
@@ -0,0 +1,48 @@
/**
* @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
*/
// Converts a string that represents a URL into a URL class instance.
export function getUrl(src: string, win: Window): URL {
// Don't use a base URL is the URL is absolute.
return isAbsoluteUrl(src) ? new URL(src) : new URL(src, win.location.href);
}
// Checks whether a URL is absolute (i.e. starts with `http://` or `https://`).
export function isAbsoluteUrl(src: string): boolean {
return /^https?:\/\//.test(src);
}
// Given a URL, extract the hostname part.
// If a URL is a relative one - the URL is returned as is.
export function extractHostname(url: string): string {
return isAbsoluteUrl(url) ? (new URL(url)).hostname : url;
}
export function isValidPath(path: unknown): boolean {
const isString = typeof path === 'string';
if (!isString || path.trim() === '') {
return false;
}
// Calling new URL() will throw if the path string is malformed
try {
const url = new URL(path);
return true;
} catch {
return false;
}
}
export function normalizePath(path: string): string {
return path.endsWith('/') ? path.slice(0, -1) : path;
}
export function normalizeSrc(src: string): string {
return src.startsWith('/') ? src.slice(1) : src;
}
+17 -1
View File
@@ -14,5 +14,21 @@ export const enum RuntimeErrorCode {
// NgSwitch errors
PARENT_NG_SWITCH_NOT_FOUND = 2000,
// Pipe errors
INVALID_PIPE_ARGUMENT = 2100
INVALID_PIPE_ARGUMENT = 2100,
// Image directive errors
UNEXPECTED_SRC_ATTR = 2950,
UNEXPECTED_SRCSET_ATTR = 2951,
INVALID_INPUT = 2952,
UNEXPECTED_INPUT_CHANGE = 2953,
REQUIRED_INPUT_MISSING = 2954,
LCP_IMG_MISSING_PRIORITY = 2955,
PRIORITY_IMG_MISSING_PRECONNECT_TAG = 2956,
INVALID_PRECONNECT_CHECK_BLOCKLIST = 2957,
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
INVALID_LOADER_ARGUMENTS = 2959,
OVERSIZED_IMAGE = 2960,
TOO_MANY_PRELOADED_IMAGES = 2961,
MISSING_BUILTIN_LOADER = 2962,
MISSING_NECESSARY_LOADER = 2963,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
/**
* @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 {ImageLoader, ImageLoaderConfig} from '@angular/common/src/directives/ng_optimized_image';
import {createCloudflareUrl} from '@angular/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader';
import {createCloudinaryUrl, provideCloudinaryLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader';
import {createImageLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/image_loader';
import {createImagekitUrl, provideImageKitLoader} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader';
import {createImgixUrl} from '@angular/common/src/directives/ng_optimized_image/image_loaders/imgix_loader';
import {isValidPath} from '@angular/common/src/directives/ng_optimized_image/url';
const absoluteUrlError = (src: string, path: string) =>
`NG02959: Image loader has detected a \`<img>\` tag with an invalid ` +
`\`ngSrc\` attribute: ${src}. This image loader expects \`ngSrc\` ` +
`to be a relative URL - however the provided value is an absolute URL. ` +
`To fix this, provide \`ngSrc\` as a path relative to the base URL ` +
`configured for this loader (\`${path}\`).`;
const invalidPathError = (path: string, formats: string) =>
`NG02959: Image loader has detected an invalid path (\`${path}\`). ` +
`To fix this, supply a path using one of the following formats: ${formats}`;
describe('Built-in image directive loaders', () => {
describe('Imgix loader', () => {
function createImgixLoader(path: string) {
const stubLoader = (createImageLoader(createImgixUrl)(path)[0] as {
provide: any,
useValue: (config: ImageLoaderConfig) => string
}).useValue;
return (config: ImageLoaderConfig) => stubLoader(config);
}
it('should construct an image loader with the given path', () => {
const path = 'https://somesite.imgix.net';
const loader = createImgixLoader(path);
const config = {src: 'img.png'};
expect(loader(config)).toBe(`${path}/img.png?auto=format`);
});
it('should handle a trailing forward slash on the path', () => {
const path = 'https://somesite.imgix.net';
const loader = createImgixLoader(`${path}/`);
const config = {src: 'img.png'};
expect(loader(config)).toBe(`${path}/img.png?auto=format`);
});
it('should handle a leading forward slash on the image src', () => {
const path = 'https://somesite.imgix.net';
const loader = createImgixLoader(path);
const config = {src: '/img.png'};
expect(loader(config)).toBe(`${path}/img.png?auto=format`);
});
it('should construct an image loader with the given path', () => {
const path = 'https://somesite.imgix.net';
const loader = createImgixLoader(path);
const config = {src: 'img.png', width: 100};
expect(loader(config)).toBe(`${path}/img.png?auto=format&w=100`);
});
it('should throw if an absolute URL is provided as a loader input', () => {
const path = 'https://somesite.imgix.net';
const src = 'https://angular.io/img.png';
const loader = createImgixLoader(path);
expect(() => loader({src})).toThrowError(absoluteUrlError(src, path));
});
});
describe('Cloudinary loader', () => {
function createCloudinaryLoader(path: string): ImageLoader {
const stubLoader = (createImageLoader(createCloudinaryUrl)(path)[0] as {
provide: any,
useValue: (config: ImageLoaderConfig) => string
}).useValue;
return (config: ImageLoaderConfig) => stubLoader(config);
}
it('should construct an image loader with the given path', () => {
const path = 'https://res.cloudinary.com/mysite';
const loader = createCloudinaryLoader(path);
expect(loader({src: 'img.png'})).toBe(`${path}/image/upload/f_auto,q_auto/img.png`);
expect(loader({
src: 'marketing/img-2.png'
})).toBe(`${path}/image/upload/f_auto,q_auto/marketing/img-2.png`);
});
describe('input validation', () => {
it('should throw if an absolute URL is provided as a loader input', () => {
const path = 'https://res.cloudinary.com/mysite';
const src = 'https://angular.io/img.png';
const loader = createCloudinaryLoader(path);
expect(() => loader({src})).toThrowError(absoluteUrlError(src, path));
});
it('should throw if the path is invalid', () => {
expect(() => provideCloudinaryLoader('my-cloudinary-account'))
.toThrowError(invalidPathError(
'my-cloudinary-account',
'https://res.cloudinary.com/mysite or https://mysite.cloudinary.com ' +
'or https://subdomain.mysite.com'));
});
it('should handle a trailing forward slash on the path', () => {
const path = 'https://res.cloudinary.com/mysite';
const loader = createCloudinaryLoader(`${path}/`);
expect(loader({src: 'img.png'})).toBe(`${path}/image/upload/f_auto,q_auto/img.png`);
});
it('should handle a leading forward slash on the image src', () => {
const path = 'https://res.cloudinary.com/mysite';
const loader = createCloudinaryLoader(path);
expect(loader({src: '/img.png'})).toBe(`${path}/image/upload/f_auto,q_auto/img.png`);
});
});
});
describe('ImageKit loader', () => {
function createImageKitLoader(path: string): ImageLoader {
const stubLoader = (createImageLoader(createImagekitUrl)(path)[0] as {
provide: any,
useValue: (config: ImageLoaderConfig) => string
}).useValue;
return (config: ImageLoaderConfig) => stubLoader(config);
}
it('should construct an image loader with the given path', () => {
const path = 'https://ik.imageengine.io/imagetest';
const loader = createImageKitLoader(path);
expect(loader({src: 'img.png'})).toBe(`${path}/tr:q-auto/img.png`);
expect(loader({src: 'marketing/img-2.png'})).toBe(`${path}/tr:q-auto/marketing/img-2.png`);
});
describe('input validation', () => {
it('should throw if an absolute URL is provided as a loader input', () => {
const path = 'https://ik.imageengine.io/imagetest';
const src = 'https://angular.io/img.png';
const loader = createImageKitLoader(path);
expect(() => loader({src})).toThrowError(absoluteUrlError(src, path));
});
it('should throw if the path is invalid', () => {
expect(() => provideImageKitLoader('my-imagekit-account'))
.toThrowError(invalidPathError(
'my-imagekit-account',
'https://ik.imagekit.io/mysite or https://subdomain.mysite.com'));
});
it('should handle a trailing forward slash on the path', () => {
const path = 'https://ik.imageengine.io/imagetest';
const loader = createImageKitLoader(`${path}/`);
expect(loader({src: 'img.png'})).toBe(`${path}/tr:q-auto/img.png`);
});
it('should handle a leading forward slash on the image src', () => {
const path = 'https://ik.imageengine.io/imagetest';
const loader = createImageKitLoader(path);
expect(loader({src: '/img.png'})).toBe(`${path}/tr:q-auto/img.png`);
});
});
});
describe('Cloudflare loader', () => {
function createCloudflareLoader(path: string): ImageLoader {
const stubLoader = (createImageLoader(createCloudflareUrl)(path)[0] as {
provide: any,
useValue: (config: ImageLoaderConfig) => string
}).useValue;
return (config: ImageLoaderConfig) => stubLoader(config);
}
it('should construct an image loader with the given path', () => {
const loader = createCloudflareLoader('https://mysite.com');
let config = {src: 'img.png'};
expect(loader(config)).toBe('https://mysite.com/cdn-cgi/image/format=auto/img.png');
});
it('should construct an image loader with the given path', () => {
const loader = createCloudflareLoader('https://mysite.com');
const config = {src: 'img.png', width: 100};
expect(loader(config)).toBe('https://mysite.com/cdn-cgi/image/format=auto,width=100/img.png');
});
it('should throw if an absolute URL is provided as a loader input', () => {
const path = 'https://mysite.com';
const src = 'https://angular.io/img.png';
const loader = createCloudflareLoader(path);
expect(() => loader({src})).toThrowError(absoluteUrlError(src, path));
});
});
describe('loader utils', () => {
it('should identify valid paths', () => {
expect(isValidPath('https://cdn.imageprovider.com/image-test')).toBe(true);
expect(isValidPath('https://cdn.imageprovider.com')).toBe(true);
expect(isValidPath('https://imageprovider.com')).toBe(true);
});
it('should reject empty paths', () => {
expect(isValidPath('')).toBe(false);
});
it('should reject path if it is not a URL', () => {
expect(isValidPath('myaccount')).toBe(false);
});
it('should reject path if it does not include a protocol', () => {
expect(isValidPath('myaccount.imageprovider.com')).toBe(false);
});
it('should reject path if is malformed', () => {
expect(isValidPath('somepa\th.imageprovider.com? few')).toBe(false);
});
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ export {getDebugNodeR2 as ɵgetDebugNodeR2} from './debug/debug_node';
export {setCurrentInjector as ɵsetCurrentInjector} from './di/injector_compatibility';
export {getInjectableDef as ɵgetInjectableDef, ɵɵInjectableDeclaration, ɵɵInjectorDef} from './di/interface/defs';
export {INJECTOR_SCOPE as ɵINJECTOR_SCOPE} from './di/scope';
export {RuntimeError as ɵRuntimeError} from './errors';
export {formatRuntimeError as ɵformatRuntimeError, RuntimeError as ɵRuntimeError} from './errors';
export {CurrencyIndex as ɵCurrencyIndex, ExtraLocaleDataIndex as ɵExtraLocaleDataIndex, findLocaleData as ɵfindLocaleData, getLocaleCurrencyCode as ɵgetLocaleCurrencyCode, getLocalePluralCase as ɵgetLocalePluralCase, LocaleDataIndex as ɵLocaleDataIndex, registerLocaleData as ɵregisterLocaleData, unregisterAllLocaleData as ɵunregisterLocaleData} from './i18n/locale_data_api';
export {DEFAULT_LOCALE_ID as ɵDEFAULT_LOCALE_ID} from './i18n/localization';
export {ComponentFactory as ɵComponentFactory} from './linker/component_factory';
@@ -0,0 +1,91 @@
load("//tools:defaults.bzl", "app_bundle", "ng_module", "protractor_web_test_suite", "ts_devserver", "ts_library")
package(default_visibility = ["//visibility:public"])
ng_module(
name = "image-directive",
srcs = [
"e2e/basic/basic.ts",
"e2e/fill-mode/fill-mode.ts",
"e2e/image-distortion/image-distortion.ts",
"e2e/lcp-check/lcp-check.ts",
"e2e/oversized-image/oversized-image.ts",
"e2e/preconnect-check/preconnect-check.ts",
"index.ts",
"playground.ts",
],
deps = [
"//packages/common",
"//packages/core",
"//packages/platform-browser",
"//packages/router",
],
)
app_bundle(
name = "bundle",
entry_point = ":index.ts",
deps = [
":image-directive",
"//packages/common",
"//packages/core",
"//packages/platform-browser",
"//packages/router",
"@npm//rxjs",
],
)
genrule(
name = "tslib",
srcs = [
"@npm//:node_modules/tslib/tslib.js",
],
outs = [
"tslib.js",
],
cmd = "cp $< $@",
)
ts_devserver(
name = "devserver",
bootstrap = ["//packages/zone.js/bundles:zone.umd.js"],
entry_module = "@angular/core/test/bundling/image-directive",
port = 4200,
scripts = [
"//tools/rxjs:rxjs_umd_modules",
],
serving_path = "/bundle.min.js",
static_files = [
"index.html",
":tslib",
"e2e/a.png",
"e2e/b.png",
"e2e/logo-500w.jpg",
"e2e/logo-1500w.jpg",
],
deps = [":image-directive"],
)
ts_library(
name = "img_dir_e2e_tests_lib",
testonly = True,
srcs = ["e2e/browser-logs-util.ts"] + glob([
"e2e/**/*.e2e-spec.ts",
]),
tsconfig = ":e2e/tsconfig-e2e.json",
deps = [
"//packages/private/testing",
"@npm//@types/selenium-webdriver",
"@npm//protractor",
],
)
protractor_web_test_suite(
name = "protractor_tests",
on_prepare = ":e2e/start-server.js",
server = ":devserver",
deps = [
":img_dir_e2e_tests_lib",
"@npm//selenium-webdriver",
],
)
@@ -0,0 +1,13 @@
* NgOptimizedImage directive testing
This folder contains a simple application that can be used as a playground for the `NgOptimizedImage` directive testing. You can run the following command to start the dev server:
```
yarn ibazel run packages/core/test/bundling/image-directive:devserver
```
There is also a set of e2e tests (powered by Protractor), which can be invoked by running:
```
yarn bazel test packages/core/test/bundling/image-directive:protractor_tests
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,29 @@
/**
* @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 {browser, by, element} from 'protractor';
import {logging} from 'selenium-webdriver';
import {collectBrowserLogs} from '../browser-logs-util';
describe('NgOptimizedImage directive', () => {
it('should render an image with an updated `src`', async () => {
await browser.get('/e2e/basic');
const imgs = element.all(by.css('img'));
const src = await imgs.get(0).getAttribute('src');
expect(/angular\.svg/.test(src)).toBe(true);
// Since there are no preconnect tags on a page,
// we expect a log in a console that mentions that.
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(1);
// Verify that the error code and a raw image src are present.
expect(logs[0].message).toMatch(/NG02956.*?a\.png/);
});
});
@@ -0,0 +1,35 @@
/**
* @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 {IMAGE_LOADER, NgOptimizedImageModule} from '@angular/common';
import {Component, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@Component({
selector: 'basic',
template: `<img ngSrc="/e2e/a.png" width="150" height="150" priority>`,
})
export class BasicComponent {
}
@NgModule({
declarations: [BasicComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: BasicComponent,
}]),
],
providers: [{
provide: IMAGE_LOADER,
useValue: () => 'https://angular.io/assets/images/logos/angular/angular.svg'
}]
})
export class BasicModule {
}
@@ -0,0 +1,36 @@
/**
* @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
*/
/* tslint:disable:no-console */
import {browser} from 'protractor';
import {logging} from 'selenium-webdriver';
export async function collectBrowserLogs(
loggingLevel: logging.Level,
collectMoreSevereErrors: boolean = false): Promise<logging.Entry[]> {
const browserLog = await browser.manage().logs().get('browser');
const collectedLogs: logging.Entry[] = [];
browserLog.forEach(logEntry => {
const msg = logEntry.message;
console.log('>> ' + msg, logEntry);
if ((!collectMoreSevereErrors && logEntry.level.value === loggingLevel.value) ||
(collectMoreSevereErrors && logEntry.level.value >= loggingLevel.value)) {
collectedLogs.push(logEntry);
}
});
return collectedLogs;
}
export async function verifyNoBrowserErrors() {
const logs =
await collectBrowserLogs(logging.Level.INFO, true /* collect more severe errors too */);
expect(logs).toEqual([]);
}
@@ -0,0 +1,45 @@
/**
* @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
*/
/* tslint:disable:no-console */
import {browser} from 'protractor';
import {logging} from 'selenium-webdriver';
import {collectBrowserLogs} from '../browser-logs-util';
describe('NgOptimizedImage directive', () => {
it('should not warn when an image in the fill mode is rendered correctly', async () => {
await browser.get('/e2e/fill-mode-passing');
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(0);
});
it('should warn if an image in the fill mode has zero height after rendering', async () => {
await browser.get('/e2e/fill-mode-failing');
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(1);
// Image loading order is not guaranteed, so all logs, rather than single entry
// needs to be checked in order to test whether a given error message is present.
const expectErrorMessageInLogs = (logs: logging.Entry[], message: string) => {
expect(logs.some((log) => {
return log.message.includes(message);
})).toBeTruthy();
};
expectErrorMessageInLogs(
logs,
'NG02952: The NgOptimizedImage directive (activated on an \\u003Cimg> element ' +
'with the `ngSrc=\\"/e2e/logo-500w.jpg\\"`) has detected that the height ' +
'of the fill-mode image is zero. This is likely because the containing element ' +
'does not have the CSS \'position\' property set to one of the following: ' +
'\\"relative\\", \\"fixed\\", or \\"absolute\\". To fix this problem, ' +
'make sure the container element has the CSS \'position\' ' +
'property defined and the height of the element is not zero.');
});
});
@@ -0,0 +1,60 @@
/**
* @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 {NgOptimizedImageModule} from '@angular/common';
import {Component, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@Component({
selector: 'fill-mode-passing',
template: `
<!-- Make sure an image in the fill mode has the size of a container -->
<div style="position: absolute; width: 100px; height: 100px;">
<img ngSrc="/e2e/logo-500w.jpg" fill priority>
</div>
`,
})
export class FillModePassingComponent {
}
@NgModule({
declarations: [FillModePassingComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: FillModePassingComponent,
}]),
],
})
export class FillModePassingModule {
}
@Component({
selector: 'fill-mode-failing',
template: `
<div style="position: relative; width: 100%;">
<img ngSrc="/e2e/logo-500w.jpg" fill priority>
</div>
`,
})
export class FillModeFailingComponent {
}
@NgModule({
declarations: [FillModeFailingComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: FillModeFailingComponent,
}]),
],
})
export class FillModeFailingModule {
}
@@ -0,0 +1,89 @@
/**
* @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
*/
/* tslint:disable:no-console */
import {browser} from 'protractor';
import {logging} from 'selenium-webdriver';
import {collectBrowserLogs} from '../browser-logs-util';
describe('NgOptimizedImage directive', () => {
it('should not warn if there is no image distortion', async () => {
await browser.get('/e2e/image-distortion-passing');
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(0);
});
it('should warn if there is image distortion', async () => {
await browser.get('/e2e/image-distortion-failing');
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(5);
// Image loading order is not guaranteed, so all logs, rather than single entry
// needs to be checked in order to test whether a given error message is present.
const expectErrorMessageInLogs = (logs: logging.Entry[], message: string) => {
expect(logs.some((log) => {
return log.message.includes(message);
})).toBeTruthy();
};
// Images with incorrect width/height attributes
expectErrorMessageInLogs(
logs,
'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' +
'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' +
'the aspect ratio of the image does not match the aspect ratio indicated by the width and height attributes. ' +
'\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' +
'\\nSupplied width and height attributes: 26w x 30h (aspect-ratio: 0.8666666666666667). ' +
'\\nTo fix this, update the width and height attributes.');
expectErrorMessageInLogs(
logs,
'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' +
'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' +
'the aspect ratio of the image does not match the aspect ratio indicated by the width and height attributes. ' +
'\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' +
'\\nSupplied width and height attributes: 24w x 240h (aspect-ratio: 0.1). ' +
'\\nTo fix this, update the width and height attributes.');
// Images with incorrect styling
expectErrorMessageInLogs(
logs,
'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' +
'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' +
'the aspect ratio of the rendered image does not match the image\'s intrinsic aspect ratio. ' +
'\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' +
'\\nRendered image size: 250w x 30h (aspect-ratio: 8.333333333333334). ' +
'\\nThis issue can occur if \\"width\\" and \\"height\\" attributes are added to an image ' +
'without updating the corresponding image styling. To fix this, adjust image styling. In most cases, ' +
'adding \\"height: auto\\" or \\"width: auto\\" to the image styling will fix this issue.');
expectErrorMessageInLogs(
logs,
'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' +
'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' +
'the aspect ratio of the rendered image does not match the image\'s intrinsic aspect ratio. ' +
'\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' +
'\\nRendered image size: 30w x 250h (aspect-ratio: 0.12). ' +
'\\nThis issue can occur if \\"width\\" and \\"height\\" attributes are added to an image ' +
'without updating the corresponding image styling. To fix this, adjust image styling. In most cases, ' +
'adding \\"height: auto\\" or \\"width: auto\\" to the image styling will fix this issue.');
// Image with incorrect width/height attributes AND incorrect styling
// This only generate only one error to ensure that users first fix the width and height
// attributes.
expectErrorMessageInLogs(
logs,
'The NgOptimizedImage directive (activated on an \\u003Cimg> element ' +
'with the \`ngSrc=\\"/e2e/b.png\\"`) has detected that ' +
'the aspect ratio of the image does not match the aspect ratio indicated by the width and height attributes. ' +
'\\nIntrinsic image size: 250w x 250h (aspect-ratio: 1). ' +
'\\nSupplied width and height attributes: 150w x 250h (aspect-ratio: 0.6). ' +
'\\nTo fix this, update the width and height attributes.');
});
});
@@ -0,0 +1,105 @@
/**
* @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 {NgOptimizedImageModule} from '@angular/common';
import {Component, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@Component({
selector: 'image-distortion-passing',
template: `
<!-- All the images in this template should not throw -->
<!-- This image is here for the sake of making sure the "LCP image is priority" assertion is passed -->
<img ngSrc="/e2e/logo-500w.jpg" width="500" height="500" priority>
<br>
<!-- width and height attributes exactly match the intrinsic size of image -->
<img ngSrc="/e2e/a.png" width="25" height="25">
<br>
<!-- supplied aspect ratio exactly matches intrinsic aspect ratio-->
<img ngSrc="/e2e/a.png" width="250" height="250">
<img ngSrc="/e2e/b.png" width="40" height="40">
<img ngSrc="/e2e/b.png" width="240" height="240">
<br>
<!-- supplied aspect ratio is similar to intrinsic aspect ratio -->
<!-- Aspect-ratio: 0.93333333333 -->
<img ngSrc="/e2e/b.png" width="28" height="30">
<!-- Aspect-ratio: 0.9 -->
<img ngSrc="/e2e/b.png" width="27" height="30">
<!-- Aspect-ratio: 1.09375 -->
<img ngSrc="/e2e/b.png" width="350" height="320">
<!-- Aspect-ratio: 1.0652173913 -->
<img ngSrc="/e2e/b.png" width="245" height="230">
<br>
<!-- Fill mode disables aspect ratio warning -->
<!-- Aspect-ratio: 0.1 -->
<img ngSrc="/e2e/b.png" width="24" height="240" disableOptimizedSrcset fill>
<br>
<!-- Supplied aspect ratio is correct & image has 0x0 rendered size -->
<img ngSrc="/e2e/a.png" width="25" height="25" style="display: none">
<br>
<!-- styling is correct -->
<img ngSrc="/e2e/a.png" width="25" height="25" style="width: 100%; height: 100%">
<img ngSrc="/e2e/a.png" width="250" height="250" style="max-width: 100%; height: 100%">
<img ngSrc="/e2e/a.png" width="25" height="25" style="height: 25%; width: 25%;">
<br>
`,
})
export class ImageDistortionPassingComponent {
}
@NgModule({
declarations: [ImageDistortionPassingComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: ImageDistortionPassingComponent,
}]),
],
})
export class ImageDistortionPassingModule {
}
@Component({
selector: 'image-distortion-failing',
template: `
<!-- With the exception of the priority image, all the images in this template should throw -->
<!-- This image is here for the sake of making sure the "LCP image is priority" assertion is passed -->
<img ngSrc="/e2e/logo-500w.jpg" width="500" height="500" priority>
<br>
<!-- These images should throw -->
<!-- Supplied aspect ratio differs from intrinsic aspect ratio by > .1 -->
<!-- Aspect-ratio: 0.86666666666 -->
<img ngSrc="/e2e/b.png" width="26" height="30" disableOptimizedSrcset>
<!-- Aspect-ratio: 0.1 -->
<img ngSrc="/e2e/b.png" width="24" height="240" disableOptimizedSrcset>
<!-- Supplied aspect ratio is incorrect & image has 0x0 rendered size -->
<img ngSrc="/e2e/a.png" width="222" height="25" style="display: none" disableOptimizedSrcset>
<br>
<!-- Image styling is causing distortion -->
<div style="width: 300px; height: 300px">
<img ngSrc="/e2e/b.png" width="250" height="250" style="width: 10%" disableOptimizedSrcset>
<img ngSrc="/e2e/b.png" width="250" height="250" style="max-height: 10%" disableOptimizedSrcset>
<!-- Images dimensions are incorrect AND image styling is incorrect -->
<img ngSrc="/e2e/b.png" width="150" height="250" style="max-height: 10%" disableOptimizedSrcset>
</div>
`,
})
export class ImageDistortionFailingComponent {
}
@NgModule({
declarations: [ImageDistortionFailingComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: ImageDistortionFailingComponent,
}]),
],
})
export class ImageDistortionFailingModule {
}
@@ -0,0 +1,36 @@
/**
* @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
*/
/* tslint:disable:no-console */
import {browser, by, element} from 'protractor';
import {logging} from 'selenium-webdriver';
import {collectBrowserLogs} from '../browser-logs-util';
describe('NgOptimizedImage directive', () => {
it('should log a warning when a `priority` is missing on an LCP image', async () => {
await browser.get('/e2e/lcp-check');
// Verify that both images were rendered.
const imgs = element.all(by.css('img'));
let srcB = await imgs.get(0).getAttribute('src');
expect(srcB.endsWith('b.png')).toBe(true);
const srcA = await imgs.get(1).getAttribute('src');
expect(srcA.endsWith('a.png')).toBe(true);
// The `b.png` image is used twice in a template.
srcB = await imgs.get(2).getAttribute('src');
expect(srcB.endsWith('b.png')).toBe(true);
// Make sure that only one warning is in the console for image `a.png`,
// since the `b.png` should be below the fold and not treated as an LCP element.
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(1);
// Verify that the error code and the image src are present in the error message.
expect(logs[0].message).toMatch(/NG02955.*?a\.png/);
});
});
@@ -0,0 +1,50 @@
/**
* @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 {NgOptimizedImageModule} from '@angular/common';
import {Component, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@Component({
selector: 'lcp-check',
template: `
<!--
'b.png' should *not* be treated as an LCP element,
since there is a bigger one right below it
-->
<img ngSrc="/e2e/b.png" width="5" height="5">
<br>
<!-- 'a.png' should be treated as an LCP element -->
<img ngSrc="/e2e/a.png" width="2500" height="2500">
<br>
<!--
'b.png' should *not* be treated as an LCP element here
as well, since it's below the fold
-->
<img ngSrc="/e2e/b.png" width="10" height="10">
`,
})
export class LcpCheckComponent {
}
@NgModule({
declarations: [LcpCheckComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: LcpCheckComponent,
}]),
],
})
export class LcpCheckModule {
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,31 @@
/**
* @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
*/
/* tslint:disable:no-console */
import {browser, by, element, ExpectedConditions} from 'protractor';
import {logging} from 'selenium-webdriver';
import {collectBrowserLogs} from '../browser-logs-util';
describe('NgOptimizedImage directive', () => {
it('should not warn if there is no oversized image', async () => {
await browser.get('/e2e/oversized-image-passing');
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(0);
});
it('should warn if rendered image size is much smaller than intrinsic size', async () => {
await browser.get('/e2e/oversized-image-failing');
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(1);
const expectedMessageRegex = /the intrinsic image is significantly larger than necessary\./;
expect(expectedMessageRegex.test(logs[0].message)).toBeTruthy();
});
});
@@ -0,0 +1,73 @@
/**
* @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 {IMAGE_LOADER, ImageLoaderConfig, NgOptimizedImageModule} from '@angular/common';
import {Component, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
const imageLoader = {
provide: IMAGE_LOADER,
useFactory: () => (config: ImageLoaderConfig) => config.src
};
@Component({
selector: 'oversized-image-passing',
providers: [imageLoader],
template: `
<!-- Image is rendered within threshold range-->
<div style="width: 500px; height: 500px">
<img ngSrc="/e2e/logo-500w.jpg" width="200" height="200" priority>
</div>
<!-- Image is rendered too small but ngSrcset set-->
<div style="width: 300px; height: 300px">
<img ngSrc="/e2e/logo-1500w.jpg" width="100" height="100" priority
ngSrcset="100w, 200w">
</div>
`,
})
export class OversizedImagePassingComponent {
}
@NgModule({
declarations: [OversizedImagePassingComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: OversizedImagePassingComponent,
}]),
],
})
export class OversizedImagePassingModule {
}
@Component({
selector: 'oversized-image-failing',
providers: [imageLoader],
template: `
<!-- Image is rendered too small -->
<div style="width: 300px; height: 300px">
<img ngSrc="/e2e/logo-1500w.jpg" width="100" height="100" priority disableOptimizedSrcset>
</div>
`,
})
export class OversizedImageFailingComponent {
}
@NgModule({
declarations: [OversizedImageFailingComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: OversizedImageFailingComponent,
}]),
],
})
export class OversizedImageFailingModule {
}
@@ -0,0 +1,51 @@
/**
* @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
*/
/* tslint:disable:no-console */
import {browser, by, element, ElementHelper} from 'protractor';
import {logging} from 'selenium-webdriver';
import {collectBrowserLogs} from '../browser-logs-util';
// Verifies that both images used in a component were rendered.
async function verifyImagesPresent(element: ElementHelper) {
const imgs = element.all(by.css('img'));
const srcA = await imgs.get(0).getAttribute('src');
expect(srcA.endsWith('a.png')).toBe(true);
const srcB = await imgs.get(1).getAttribute('src');
expect(srcB.endsWith('b.png')).toBe(true);
}
describe('NgOptimizedImage directive', () => {
it('should log a warning when there is no preconnect for priority images', async () => {
await browser.get('/e2e/preconnect-check');
await verifyImagesPresent(element);
// Make sure that only one warning is in the console for both images,
// because they both have the same base URL (which is used to look for
// corresponding `<link rel="preconnect">` tags).
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(1);
// Verify that the error code and a raw image src are present in the
// error message.
expect(logs[0].message).toMatch(/NG02956.*?a\.png/);
});
it('should not produce any warnings in the console when a preconnect tag is present',
async () => {
await browser.get('/e2e/preconnect-check?preconnect');
await verifyImagesPresent(element);
// Make sure there are no browser logs.
const logs = await collectBrowserLogs(logging.Level.WARNING);
expect(logs.length).toEqual(0);
});
});
@@ -0,0 +1,67 @@
/**
* @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 {DOCUMENT, IMAGE_LOADER, NgOptimizedImageModule} from '@angular/common';
import {Component, Inject, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@Component({
selector: 'preconnect-check',
template: `
<img ngSrc="/e2e/a.png" width="50" height="50" priority>
<img ngSrc="/e2e/b.png" width="50" height="50" priority>
<img ngSrc="/e2e/c.png" width="50" height="50">
`,
})
export class PreconnectCheckComponent {
constructor(@Inject(DOCUMENT) private doc: Document) {
this.createRequestedLinkElements();
}
/**
* Setup an environment required for e2e testing: create the necessary `<link>` elements in the
* `document.head`, so that the `NgOptimizedImage` logic can be verified in various scenarios.
*/
private createRequestedLinkElements() {
const win = this.doc.defaultView;
if (!win) return;
const url = new URL(win.location.href).searchParams;
const preconnect = url.get('preconnect');
if (preconnect !== null) {
const link = this.createLinkElement('preconnect', 'https://angular.io');
this.doc.head.appendChild(link);
}
}
/**
* Helper method to create a simple `<link>` element based on inputs.
*/
private createLinkElement(rel: string, href: string, as?: string): HTMLLinkElement {
const link = this.doc.createElement('link');
link.rel = rel;
link.href = href;
if (as) link.as = as;
return link;
}
}
@NgModule({
declarations: [PreconnectCheckComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: PreconnectCheckComponent,
}]),
],
providers: [{
provide: IMAGE_LOADER,
useValue: (config: {src: string}) => `https://angular.io/assets/images/${config.src}`
}]
})
export class PreconnectCheckModule {
}
@@ -0,0 +1,21 @@
/**
* @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
*/
const protractorUtils = require('@bazel/protractor/protractor-utils');
const protractor = require('protractor');
/**
* Helper function to start up a server for testing using Protractor utils.
* Used as a part of the `protractor_web_test_suite` rule configuration.
*/
module.exports = async function(config) {
const {port} = await protractorUtils.runServer(config.workspace, config.server, '--port', []);
const serverUrl = `http://localhost:${port}`;
protractor.browser.baseUrl = serverUrl;
};
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"lib": ["es2015"],
"types": ["node", "jasminewd2"]
}
}
@@ -0,0 +1,33 @@
<!doctype html>
<html>
<head>
<title>Image Directive Example</title>
<base href="/">
</head>
<body>
<!-- The Angular application will be bootstrapped into this element. -->
<app-root></app-root>
<!--
Script tag which bootstraps the application. Use `?debug` in URL to select
the debug version of the script.
There are two scripts sources: `bundle.min.js` and `bundle.debug.min.js` You can
switch between which bundle the browser loads to experiment with the application.
- `bundle.min.js`: Is what the site would serve to their users. It has gone
through rollup, build-optimizer, and uglify with tree shaking.
- `bundle.debug.min.js`: Is what the developer would like to see when debugging
the application. It has also gone through full pipeline of esbuild, babel optimization,
plugins from the devkit and terser, however mangling is disabled and the minified
file is formatted using prettier (to ease debugging).
-->
<script>
document.write('<script src="' +
(document.location.search.endsWith('debug') ? '/bundle.debug.min.js' : '/bundle.min.js') +
'"></' + 'script>');
</script>
</body>
</html>
@@ -0,0 +1,80 @@
/**
* @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 {Component, NgModule} from '@angular/core';
import {BrowserModule, platformBrowser} from '@angular/platform-browser';
import {RouterModule} from '@angular/router';
const ROUTES = [
// Paths that contain components for test/demo purposes:
{
path: '',
loadChildren: () => import('./playground').then(mod => mod.PlaygroundModule),
},
// Paths below are used for e2e testing:
{
path: 'e2e/basic',
loadChildren: () => import('./e2e/basic/basic').then(mod => mod.BasicModule),
},
{
path: 'e2e/lcp-check',
loadChildren: () => import('./e2e/lcp-check/lcp-check').then(mod => mod.LcpCheckModule),
},
{
path: 'e2e/preconnect-check',
loadChildren: () =>
import('./e2e/preconnect-check/preconnect-check').then(mod => mod.PreconnectCheckModule),
},
{
path: 'e2e/image-distortion-passing',
loadChildren: () => import('./e2e/image-distortion/image-distortion')
.then(mod => mod.ImageDistortionPassingModule),
},
{
path: 'e2e/image-distortion-failing',
loadChildren: () => import('./e2e/image-distortion/image-distortion')
.then(mod => mod.ImageDistortionFailingModule),
},
{
path: 'e2e/oversized-image-passing',
loadChildren: () => import('./e2e/oversized-image/oversized-image')
.then(mod => mod.OversizedImagePassingModule),
},
{
path: 'e2e/oversized-image-failing',
loadChildren: () => import('./e2e/oversized-image/oversized-image')
.then(mod => mod.OversizedImageFailingModule),
},
{
path: 'e2e/fill-mode-passing',
loadChildren: () => import('./e2e/fill-mode/fill-mode').then(mod => mod.FillModePassingModule),
},
{
path: 'e2e/fill-mode-failing',
loadChildren: () => import('./e2e/fill-mode/fill-mode').then(mod => mod.FillModeFailingModule),
},
];
@Component({
selector: 'app-root',
template: '<router-outlet></router-outlet>',
})
export class RootComponent {
}
@NgModule({
declarations: [RootComponent],
imports: [BrowserModule, RouterModule.forRoot(ROUTES)],
})
class RootModule {
ngDoBootstrap(app: any) {
app.bootstrap(RootComponent);
}
}
(window as any).waitForApp = platformBrowser().bootstrapModule(RootModule);
@@ -0,0 +1,63 @@
/**
* @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 {NgOptimizedImageModule, provideImgixLoader} from '@angular/common';
import {Component, NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
@Component({
selector: 'basic',
styles: [`
h1 {
display: flex;
align-items: center;
}
main {
border: 1px solid blue;
margin: 16px;
padding: 16px;
}
.spacer {
height: 3000px;
}
main img {
width: 100%;
height: auto;
}
`],
template: `
<h1>
<img ngSrc="a.png" width="50" height="50" priority ngSrcset="1x, 2x">
<span>Angular image app</span>
</h1>
<main>
<div class="spacer"></div>
<img ngSrc="hermes2.jpeg" ngSrcset="100w, 200w, 1000w, 2000w" width="1791" height="1008">
</main>
`,
providers: [provideImgixLoader('https://aurora-project.imgix.net')],
})
export class PlaygroundComponent {
}
@NgModule({
declarations: [PlaygroundComponent],
imports: [
NgOptimizedImageModule,
RouterModule.forChild([{
path: '',
component: PlaygroundComponent,
}]),
],
providers: [provideImgixLoader('https://aurora-project.imgix.net')],
})
export class PlaygroundModule {
}
+48
View File
@@ -50,6 +50,54 @@ export function withBody<T extends Function>(html: string, blockFn: T): T {
} as any;
}
/**
* Wraps a function in a new function which sets up document and HTML for running a test.
*
* This function wraps an existing testing function. The wrapper adds HTML to the `head` element of
* the `document` and subsequently tears it down.
*
* This function can be used with `async await` and `Promise`s. If the wrapped function returns a
* promise (or is `async`) then the teardown is delayed until that `Promise` is resolved.
*
* In the NodeJS environment this function detects if `document` is present and if not, it creates
* one by loading `domino` and installing it.
*
* Example:
*
* ```
* describe('something', () => {
* it('should do something', withHead('<link rel="preconnect" href="...">', async () => {
* // ...
* }));
* });
* ```
*
* @param html HTML which should be inserted into the `head` of the `document`.
* @param blockFn function to wrap. The function can return promise or be `async`.
*/
export function withHead<T extends Function>(html: string, blockFn: T): T {
return wrapTestFn(() => document.head, html, blockFn);
}
/**
* Wraps provided function (which typically contains the code of a test) into a new function that
* performs the necessary setup of the environment.
*/
function wrapTestFn<T extends Function>(
elementGetter: () => HTMLElement, html: string, blockFn: T): T {
return function(done: DoneFn) {
if (typeof blockFn === 'function') {
elementGetter().innerHTML = html;
const blockReturn = blockFn();
if (blockReturn instanceof Promise) {
blockReturn.then(done, done.fail);
} else {
done();
}
}
} as any;
}
/**
* Runs jasmine expectations against the provided keys for `ngDevMode`.
*