/**
* Copyright 2018 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const {
appendChild,
createElement,
hasAttribute,
remove,
insertAfter,
nextNode,
firstChildByTag,
} = require('../NodeUtils');
const {findMetaViewport, skipNodeAndChildren} = require('../HtmlDomHelper');
const {isValidImageSrcURL} = require('../URLUtils');
const {isTemplate, isAmpStory} = require('../AmpConstants');
// Images smaller than 150px are considered tiny
const TINY_IMG_THRESHOLD = 150;
// Maximum number of hero images defined via data-hero
const DATA_HERO_MAX = 2;
/**
* OptimizeHeroImage - this transformers optimizes image rendering times for hero images. For hero
* images it will:
*
* 1. Inject a preload hint (if possible)
* 2. Generate an img tag enabling the browser to render the image without the AMP runtime being loaded.
*
* Hero images are either identified automatically or can be explicitly defined by adding an `data-hero`
* attribute to the element.
*
* This transformer supports the following options:
*
* * `optimizeHeroImages`: [true|false] - enables or disables hero image optimization. The default is `true`.
* * `maxHeroImageCount`: [int] - defines the maximum number of hero images per page. The default is `2`.
*/
class OptimizeHeroImage {
constructor(config) {
this.log = config.log;
this.enabled = config.optimizeHeroImages !== false || config.preloadHeroImage !== false;
this.maxHeroImageCount = config.maxHeroImageCount || DATA_HERO_MAX;
if (config.preloadHeroImage) {
this.log.info(
'`preloadHeroImage` option has been deprecated. Use `optimizeHeroImages` instead'
);
}
}
async transform(root, params) {
if (params.preloadHeroImage) {
this.log.info(
'`preloadHeroImage` option has been deprecated. Use `optimizeHeroImages` instead'
);
}
if (!this.enabled || params.optimizeHeroImages === false || params.preloadHeroImage === false) {
return;
}
const maxHeroImageCount = params.maxHeroImageCount || this.maxHeroImageCount;
const html = firstChildByTag(root, 'html');
const head = firstChildByTag(html, 'head');
const body = firstChildByTag(html, 'body');
if (!body || !head) return;
const heroImages = this.findHeroImages(body);
let referenceNode = findMetaViewport(head);
let heroImageCount = heroImages.length;
if (heroImageCount > maxHeroImageCount) {
this.log.warn(
`Found ${heroImageCount} hero elements on the page, but the maximum is set to ${maxHeroImageCount}. The limit can be configured via the 'maxHeroImage' parameter.`
);
heroImageCount = maxHeroImageCount;
}
const isStory = isAmpStory(head);
for (let i = 0; i < heroImageCount; i++) {
const heroImage = heroImages[i];
this.generatePreload(heroImage, head, referenceNode);
// AMP Stories don't support ssr'd amp-img yet
// See https://github.com/ampproject/amphtml/issues/29850
if (!isStory) {
this.generateImg(heroImage.ampImg);
}
}
}
generatePreload(heroImage, head, referenceNode) {
if (heroImage.srcset) {
this.log.debug(
"Could not preload hero image as it's using srcset, which is currently only supported Chromium-based browsers (see https://web.dev/preload-responsive-images/).",
heroImage.src
);
return;
}
if (this.hasExistingImagePreload(head, heroImage.src)) {
return;
}
const preload = createElement('link', {
'rel': 'preload',
'href': heroImage.src,
'as': 'image',
'data-hero': '',
});
if (!heroImage.media) {
// We can only safely preload a hero image if there's a media attribute
// as we can't detect whether it's hidden on certain viewport sizes otherwise.
return;
}
preload.attribs.media = heroImage.media;
insertAfter(head, preload, referenceNode);
}
hasExistingImagePreload(head, src) {
return head.children.some((node) => {
if (node.tagName !== 'link') {
return false;
}
if (!hasAttribute(node, 'rel')) {
return false;
}
if (node.attribs.rel !== 'preload') {
return false;
}
if (node.attribs.as !== 'image') {
return false;
}
return node.attribs.href === src;
});
}
findHeroImages(root) {
let heroImageCandidate = null;
let heroImages = [];
let node = root;
let seenParagraphCount = 0;
// Walk over all nodes in the body
while (node !== null) {
if (node.tagName === 'p') {
seenParagraphCount++;
}
// Look for data-hero attribute
this.addImageWithDataHero(node, heroImages);
// Auto detect a hero image in case data-hero is not used,
// but only if before the second paragraph.
if (!heroImageCandidate && seenParagraphCount < 2 && heroImages.length === 0) {
heroImageCandidate = this.isCandidateHeroImage(node);
}
if (isTemplate(node)) {
// Ignore images inside templates
node = skipNodeAndChildren(node);
} else {
node = nextNode(node);
}
}
// Optimize data-hero element if defined
if (heroImages.length > 0) {
return heroImages;
}
// Fallback to auto detected hero image if available
if (heroImageCandidate) {
return [heroImageCandidate];
}
// No hero images to optimize
return [];
}
addImageWithDataHero(node, heroImages) {
if (node.tagName === 'amp-img' && hasAttribute(node, 'data-hero')) {
const {src, media, srcset} = node.attribs;
heroImages.push({
ampImg: node,
src,
media,
srcset,
});
} else if (this.isAmpIframe(node) && hasAttribute(node, 'data-hero')) {
const placeholder = this.getPlaceholderImage(node);
if (placeholder) {
heroImages.push(placeholder);
}
}
}
isCandidateHeroImage(node) {
if (!node.tagName) {
return null;
}
const layout = node.attribs ? node.attribs.layout : '';
if (layout === 'nodisplay') {
return null;
}
if (node.tagName === 'amp-img') {
return this.isCandidateImageForPreloading(node);
}
if (node.tagName === 'amp-video') {
return this.isCandidateVideoPosterImage(node);
}
if (this.isAmpIframe(node)) {
return this.isCandidateIframePlaceholderImage(node);
}
return null;
}
isAmpIframe(node) {
return node.tagName === 'amp-iframe' || node.tagName === 'amp-video-iframe';
}
// For a given node or any node that has poster attribute, and
// qualifies as hero image, returns the HeroImageSrcs.
isCandidateVideoPosterImage(ampVideo) {
const poster = ampVideo.attribs.poster;
if (!poster) return null;
if (!isValidImageSrcURL(poster)) {
return null;
}
const {layout, width, height, media} = ampVideo.attribs;
if (this.isTinyNode(layout, width, height)) {
return null;
}
return {src: poster, media, srcset: ''};
}
isCandidateIframePlaceholderImage(ampIframe) {
// Placeholder amp-img is required to preload image for iframe.
if (!ampIframe.children || ampIframe.children.length === 0) {
return null;
}
const {layout, width, height} = ampIframe.attribs;
if (this.isTinyNode(layout, width, height)) return null;
return this.getPlaceholderImage(ampIframe);
}
getPlaceholderImage(ampIframe) {
for (const child of ampIframe.children) {
if (
child.tagName === 'amp-img' &&
hasAttribute(child, 'placeholder') &&
isValidImageSrcURL(child.attribs.src)
) {
return {
ampImg: child,
src: child.attribs.src,
media: ampIframe.attribs.media,
srcset: child.attribs.srcset || '',
};
}
}
return null;
}
// Checks if node qualifies to be a hero image. Returns HeroImageSrcs if the
// node is a hero image. The hero image here can come from one of ,
// , , .
isCandidateImageForPreloading(ampImg) {
const src = ampImg.attribs.src;
if (!src) {
return null;
}
if (!isValidImageSrcURL(src)) {
return null;
}
let {width, height, srcset, layout, media} = ampImg.attribs;
if (!width && !height) {
if (layout === 'fill') {
({width, height} = this.nodeDimensionsFromParent(ampImg));
} else {
return null;
}
}
if (this.isTinyNode(layout, width, height)) {
return null;
}
return {ampImg, src, srcset, media};
}
// Any node with width or height less than 150 pixels and a non-responsive layout.
isTinyNode(layout, width, height) {
if (width <= 0 || height <= 0) return true;
if (layout === 'responsive') {
return false;
}
return width < TINY_IMG_THRESHOLD || height < TINY_IMG_THRESHOLD;
}
nodeDimensionsFromParent(node) {
while (node.parent) {
node = node.parent;
if (!node.attribs) {
continue;
}
const width = node.attribs.width;
const height = node.attribs.height;
if (!width && !height) {
continue;
}
return {width, height};
}
return {width: 0, height: 0};
}
generateImg(node) {
if (!node) {
return;
}
node.attribs['i-amphtml-ssr'] = '';
// Create img node
const imgNode = createElement('img', {
class: 'i-amphtml-fill-content i-amphtml-replaced-content',
decoding: 'async',
});
// If the image was detected as hero image candidate (and thus lacks an explicit
// data-hero), mark it as a hero and add loading=lazy to guard against making
// the page performance even worse by eagerly loading an image outside the viewport.
// But if there is a noscript > img then preserve its original loading attribute.
const noscriptImg = this.getNoscriptFallbackImage(node);
if (noscriptImg) {
// Preserve the original loading attribute from the noscript fallback img.
if (hasAttribute(noscriptImg, 'loading')) {
imgNode.attribs.loading = noscriptImg.attribs.loading;
}
// Remove any noscript fallback when an amp-img is pre-rendered.
remove(noscriptImg.parent);
} else if (!this.isMarkedAsHeroImage(node)) {
imgNode.attribs['loading'] = 'lazy';
}
if (!hasAttribute(node.attribs, 'data-hero')) {
node.attribs['data-hero'] = '';
}
// Copy attributes
const attributesToCopy = [
'alt',
'attribution',
'referrerpolicy',
'src',
'srcset',
'sizes',
'title',
];
for (const attr of attributesToCopy) {
if (hasAttribute(node, attr)) {
imgNode.attribs[attr] = node.attribs[attr];
}
}
// Generate styles for object-fit and object-position
const styles = [];
const objectFit = node.attribs['object-fit'];
if (objectFit) {
styles.push(`object-fit:${objectFit}`);
}
const objectPosition = node.attribs['object-position'];
if (objectPosition) {
styles.push(`object-position:${objectPosition}`);
}
if (styles.length > 0) {
imgNode.attribs.style = styles.join(';');
}
appendChild(node, imgNode);
}
getNoscriptFallbackImage(node) {
const noscript = firstChildByTag(node, 'noscript');
if (!noscript) {
return null;
}
return firstChildByTag(noscript, 'img');
}
isMarkedAsHeroImage(node) {
while (node) {
if (!node.tagName) {
node = node.parent;
continue;
}
if (hasAttribute(node, 'data-hero')) {
return true;
}
if (node.tagName === 'body' || node.tagName === 'html') {
return false;
}
node = node.parent;
}
return false;
}
}
/** @module OptimizeHeroImage */
module.exports = OptimizeHeroImage;