mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(common): Backport NgOptimizedImage to v13
Backport the image optimization features from verion 15 to version 13
This commit is contained in:
committed by
Joey Perrott
parent
69a906479f
commit
ae34dbca1b
@@ -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/**',
|
||||
|
||||
@@ -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">
|
||||
|
||||
<img ngSrc="cat.jpg">
|
||||
|
||||
</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">
|
||||
|
||||
<img ngSrc="cat.jpg" width="400" height="200" priority>
|
||||
|
||||
</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">
|
||||
|
||||
<img ngSrc="cat.jpg" width="400" height="200">
|
||||
|
||||
</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">
|
||||
|
||||
<img ngSrc="cat.jpg" fill>
|
||||
|
||||
</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">
|
||||
|
||||
<link rel="preconnect" href="https://my.cdn.origin" />
|
||||
|
||||
</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">
|
||||
|
||||
<img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w">
|
||||
|
||||
</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">
|
||||
|
||||
<img ngSrc="hero.jpg" ngSrcset="100w, 200w, 300w" sizes="50vw">
|
||||
|
||||
</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">
|
||||
|
||||
<img ngSrc="about.jpg" disableOptimizedSrcset>
|
||||
|
||||
</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">
|
||||
|
||||
<img ngSrc="cat.jpg" width="400" height="200" loading="eager">
|
||||
|
||||
</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">
|
||||
|
||||
<img ngSrc="cat.jpg" width="400" height="200" sizes="(max-width: 768px) 100vw, 50vw">
|
||||
|
||||
</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">
|
||||
|
||||
<img ngSrc="profile.jpg" width="300" height="300" [loaderParams]="{roundedCorners: true}">
|
||||
|
||||
</code-example>
|
||||
|
||||
<!-- links -->
|
||||
|
||||
<!-- external links -->
|
||||
|
||||
<!--end links -->
|
||||
|
||||
@reviewed 2022-11-07
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
}
|
||||
+89
@@ -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 |
+31
@@ -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 {
|
||||
}
|
||||
+51
@@ -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 {
|
||||
}
|
||||
@@ -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`.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user