Skip to content

Commit

Permalink
add experimental amp-img -> img transformation (#859)
Browse files Browse the repository at this point in the history
This commit introduces the data-hero attribute as a means to explicitly
markup hero amp-img or amp-iframe elemenents.

Other changes:

* don't bail out if there are any kind of image preloads, instead check
the img src to avoid duplicates
* don't use loading=lazy
* print warning if there are too many hero elements on a page
  • Loading branch information
sebastianbenz authored Jul 15, 2020
1 parent 9d67f26 commit 6f5d9dd
Show file tree
Hide file tree
Showing 22 changed files with 345 additions and 63 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/optimizer/lib/DomTransformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const TRANSFORMATIONS_AMP_FIRST = [
'AutoExtensionImporter',
// Applies image optimizations, must run before PreloadHeroImage
'OptimizeImages',
// Detect hero image and preload link rel=preload
// Detect hero image and preload link rel=preload, needs to run after OptimizeImages
'PreloadHeroImage',
// Applies server-side-rendering optimizations
'ServerSideRendering',
Expand Down
218 changes: 162 additions & 56 deletions packages/optimizer/lib/transformers/PreloadHeroImage.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,44 @@

'use strict';

const {createElement, hasAttribute, insertAfter, firstChildByTag} = require('../NodeUtils');
const {findMetaViewport} = require('../HtmlDomHelper');
const {
appendChild,
createElement,
hasAttribute,
insertAfter,
nextNode,
firstChildByTag,
} = require('../NodeUtils');
const {findMetaViewport, skipNodeAndChildren} = require('../HtmlDomHelper');
const {isValidImageSrcURL} = require('../URLUtils');
const {isTemplate} = require('../AmpConstants');
const parseSrcSet = require('../parseSrcSet');

// 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;

/**
* PreloadHeroImage - This transformer identifies a hero image or
* important images on the document and attaches <link rel="preload"> element. It
* will only add a hero image if there is not already an existing image preload.
* PreloadHeroImage - this transformers optimizes image rendering times for hero images. For hero
* images it will:
*
* This transformer supports the following option:
* 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:
*
* * `preloadHeroImage`: [true|false] - enables or disables hero image preloading. The default is `true`.
*/
class PreloadHeroImage {
constructor(config) {
this.log = config.log;
this.enabled = config.preloadHeroImage !== false;
this.experimentImg = config.experimentImg;
}

async transform(root, params) {
if (!this.enabled || params.preloadHeroImage === false) {
return;
Expand All @@ -48,17 +63,34 @@ class PreloadHeroImage {
const body = firstChildByTag(html, 'body');
if (!body || !head) return;

if (this.hasExistingImagePreload(head)) {
return;
}
const heroImages = this.findHeroImages(body);
let referenceNode = findMetaViewport(head);

const heroImage = this.findHeroImage(body);
let heroImageCount = heroImages.length;
if (heroImageCount > DATA_HERO_MAX) {
this.log.warn(
`Found ${heroImageCount} hero elements on the page. AMP currently only supports a maximum of ${DATA_HERO_MAX} elements.`
);
heroImageCount = DATA_HERO_MAX;
}
for (let i = 0; i < heroImageCount; i++) {
const heroImage = heroImages[i];
this.generatePreload(heroImage, head, referenceNode);
this.generateImg(heroImage.ampImg);
}
}

if (!heroImage) {
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;
}
let referenceNode = findMetaViewport(head);

const preload = createElement('link', {
'rel': 'preload',
'href': heroImage.src,
Expand All @@ -68,65 +100,99 @@ class PreloadHeroImage {
if (heroImage.media) {
preload.attribs.media = heroImage.media;
}
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;
}
insertAfter(head, preload, referenceNode);
}

hasExistingImagePreload(head) {
return head.children.some(this.isImagePreload);
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;
});
}

isImagePreload(node) {
if (node.tagName !== 'link') {
return false;
findHeroImages(root) {
let heroImageCandidate = null;
let heroImages = [];
let node = root;
// Walk over all nodes in the body
while (node !== null) {
// Look for data-hero attribute
this.addImageWithDataHero(node, heroImages);
// Auto detect a hero image in case data-hero is not used
if (!heroImageCandidate && heroImages.length === 0) {
heroImageCandidate = this.isCandidateHeroImage(node);
}
if (isTemplate(root)) {
// Ignore images inside templates
node = skipNodeAndChildren(node);
} else {
node = nextNode(node);
}
}
if (!hasAttribute(node, 'rel')) {
return false;
// Optimize data-hero element if defined
if (heroImages.length > 0) {
return heroImages;
}
if (node.attribs.rel !== 'preload') {
return false;
// Fallback to auto detected hero image if available
if (heroImageCandidate) {
return [heroImageCandidate];
}
return node.attribs.as === 'image';
// No hero images to optimize
return [];
}

findHeroImage(root) {
if (!root.tagName) {
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;
}
// Ignore images inside templates
if (isTemplate(root)) {
const layout = node.attribs ? node.attribs.layout : '';
if (layout === 'nodisplay') {
return null;
}

const layout = root.attribs ? root.attribs.layout : '';
if (layout === 'nodisplay') return null;

if (root.tagName === 'amp-img') {
return this.isCandidateImageForPreloading(root);
if (node.tagName === 'amp-img') {
return this.isCandidateImageForPreloading(node);
}
if (root.tagName === 'amp-video') {
return this.isCandidateVideoPosterImage(root);
if (node.tagName === 'amp-video') {
return this.isCandidateVideoPosterImage(node);
}
if (root.tagName === 'amp-iframe' || root.tagName === 'amp-video-iframe') {
return this.isCandidateIframePlaceholderImage(root);
}

let heroImage;
for (const child of root.children) {
const heroImage = this.findHeroImage(child);
if (heroImage) {
return heroImage;
}
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 <amp-video> node or any node that has poster attribute, and
// qualifies as hero image, returns the HeroImageSrcs.
isCandidateVideoPosterImage(ampVideo) {
Expand All @@ -149,17 +215,26 @@ class PreloadHeroImage {
return null;
}

const {layout, width, height, media} = ampIframe.attribs;
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 {src: child.attribs.src, media, srcset: child.attribs.srcset || ''};
return {
ampImg: child,
src: child.attribs.src,
media: ampIframe.attribs.media,
srcset: child.attribs.srcset || '',
};
}
}
return null;
Expand Down Expand Up @@ -189,7 +264,7 @@ class PreloadHeroImage {
if (this.isTinyNode(layout, width, height)) {
return null;
}
return {src, srcset, media};
return {ampImg, src, srcset, media};
}

// Any node with width or height less than 150 pixels and a non-responsive layout.
Expand All @@ -216,6 +291,37 @@ class PreloadHeroImage {
}
return {width: 0, height: 0};
}

generateImg(node) {
if (!this.experimentImg) {
return;
}
if (!node) {
return;
}
const imgNode = createElement('img', {
class: 'i-amphtml-fill-content i-amphtml-replaced-content',
decoding: 'async',
});
const attributesToCopy = [
'alt',
'attribution',
'object-fit',
'object-position',
'referrerpolicy',
'src',
'srcset',
'sizes',
'title',
];
for (const attr of attributesToCopy) {
if (hasAttribute(node, attr)) {
imgNode.attribs[attr] = node.attribs[attr];
}
}
node.attribs['i-amphtml-ssr'] = '';
appendChild(node, imgNode);
}
}

/** @module PreloadHeroImage */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<html>
<head>
<script async src="https://cdn.ampproject.org/v0.js"></script>
<link rel="preload" href="http://example.com/foo.png" as="image" data-hero media="(max-width: 649px)">
</head>
<body>
<amp-iframe src="/test" layout="responsive" width="320" height="900" media="(max-width: 649px)">
<amp-img placeholder layout="fill" src="http://example.com/foo.png" i-amphtml-ssr><img class="i-amphtml-fill-content i-amphtml-replaced-content" decoding="async" src="http://example.com/foo.png"></amp-img>
</amp-iframe>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<html>
<head>
<script async src="https://cdn.ampproject.org/v0.js"></script>
</head>
<body>
<amp-iframe src="/test" layout="responsive" width="320" height="900" media="(max-width: 649px)">
<amp-img placeholder layout="fill" src="http://example.com/foo.png"></amp-img>
</amp-iframe>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<html>
<head>
<script async src="https://cdn.ampproject.org/v0.js"></script>
<link rel="preload" href="hero.jpg" as="image" data-hero>
</head>
<body>
<amp-iframe src="/no-hero" layout="responsive" width="320" height="900">
<amp-img placeholder layout="fill" src="no-hero.jpg"></amp-img>
</amp-iframe>
<amp-iframe data-hero src="/hero" layout="responsive" width="320" height="900">
<amp-img placeholder layout="fill" src="hero.jpg" i-amphtml-ssr><img class="i-amphtml-fill-content i-amphtml-replaced-content" decoding="async" src="hero.jpg"></amp-img>
</amp-iframe>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<html>
<head>
<script async src="https://cdn.ampproject.org/v0.js"></script>
</head>
<body>
<amp-iframe src="/no-hero" layout="responsive" width="320" height="900">
<amp-img placeholder layout="fill" src="no-hero.jpg"></amp-img>
</amp-iframe>
<amp-iframe data-hero src="/hero" layout="responsive" width="320" height="900">
<amp-img placeholder layout="fill" src="hero.jpg"></amp-img>
</amp-iframe>
</body>
</html>
Loading

0 comments on commit 6f5d9dd

Please sign in to comment.