Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add AutoExtensionImporter #569

Merged
merged 6 commits into from
Jan 16, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/optimizer/lib/RuntimeHostHelper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Copyright 2020 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 {
AMP_CACHE_HOST,
appendRuntimeVersion,
} = require('./AmpConstants.js');

function calculateHost(params) {
let ampUrlPrefix = params.ampUrlPrefix || AMP_CACHE_HOST;
fstanis marked this conversation as resolved.
Show resolved Hide resolved
if (params.ampRuntimeVersion && !params.ampUrlPrefix) {
ampUrlPrefix = appendRuntimeVersion(ampUrlPrefix, params.ampRuntimeVersion);
}
let dynamicAmpUrlPrefix = ampUrlPrefix;
if (params.rewriteDynamicComponents === false) {
dynamicAmpUrlPrefix = AMP_CACHE_HOST;
if (params.ampRuntimeVersion) {
dynamicAmpUrlPrefix = appendRuntimeVersion(dynamicAmpUrlPrefix, params.ampRuntimeVersion);
}
}
return {
dynamicAmpUrlPrefix,
ampUrlPrefix,
};
}

module.exports = {
calculateHost,
};

269 changes: 269 additions & 0 deletions packages/optimizer/lib/transformers/AutoExtensionImporter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/**
* Copyright 2020 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 {findMetaViewport} = require('../HtmlDomHelper');
const {calculateHost} = require('../RuntimeHostHelper');
const validatorRules = require('@ampproject/toolbox-validator-rules');

const BIND_SHORT_FORM_PREFIX = 'bind';

// Some AMP component don't bring their own tag, but enable new attributes on other
// elements. Most are included in the AMP validation rules, but some are not. These
// need to be defined manually here.
const manualAttributeToExtensionMapping = new Map([
fstanis marked this conversation as resolved.
Show resolved Hide resolved
['mask', 'amp-inputmask'],
['lightbox', 'amp-lightbox-gallery'],
]);

/**
* Extension Auto Importer - this transformer auto imports all missing AMP extensions.
*
* The importer analyzes the HTML source code and identifies missing AMP extension imports
* using multiple strategies:
*
* - use validation rules to map used AMP tags to required AMP extensions.
* - use validation rules to map used AMP attributes to required AMP extensions.
* - manually specifiy attribute to extension mappings if this information is not available in the
* validation rules.
* - mnullay implement AMP extension detection for a few corner cases.
*
* This importer also enables a shortcode `bindtext` instead of `data-amp-bind-text` for specifying
* AMP bindings when the square bracket notation (`[text]`) is not available. To avoid accidently
* rewriting non-AMP attributes, the transformer uses the AMP validation rules to only rename bindable
* attributes as specified in the validation rules.
*/
class AutoExtensionImporter {
constructor(config) {
this.enabled = config.autoExtensionImport !== 'false' && config.autoExtensionImport !== false;
fstanis marked this conversation as resolved.
Show resolved Hide resolved
this.log_ = config.log.tag('AutoExtensionImporter');

// We use the validation rules to infer extension imports. The rules are downloaded once and for
// efficency, we initially extract all needed rules
this.extensionSpec = validatorRules.fetch().then((rules) => {
fstanis marked this conversation as resolved.
Show resolved Hide resolved
// Map extension names to more info
const extensionsMap = new Map(rules.extensions.map((ext) => [ext.name, {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this... you may want to take htmlFormat into account. For example, AMP for Email doesn't support amp-carousel 0.2.

More importantly, this assumes there's exactly one extension for a given ext.name which is incorrect because of the above. I'm not sure what the current behavior is, but it will give you an arbitrary amp-carousel that may be the email one (and thus 0.1) or the regular one (and thus 0.1 and 0.2).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup. That's already on my todo list.

name: ext.name,
type: ext.extensionType === 'CUSTOM_TEMPLATE' ? 'custom-template' : 'custom-element',
version: ext.version.filter((v) => v !== 'latest'),
}]));
// Maps tags (e.g. amp-state) to their extension (e.g. amp-bind)
const tagToExtensionsMapping = new Map();
// Maps tags to their extension specific allowed attributes
// (e.g. amp-img => amp-fx => amp-fx-collection)
const tagToAttributeMapping = new Map();
// Maps tags to their bindable attributes (e.g. div => text)
const tagToBindAttributeMapping = new Map();

// Iterate over all available tags
for (const tag of rules.tags) {
const tagName = tag.tagName.toLowerCase();

// Map amp tags to their required extension(s)
if (tagName.startsWith('amp-')) {
tagToExtensionsMapping.set(
tagName,
tag.requiresExtension,
);
}
// Hack: fix missing attribute dependencies (e.g. amp-img => lightbox => amp-lightbox-gallery)
tag.attrs.filter((a) => manualAttributeToExtensionMapping.has(a.name))
.forEach((a) => {
a.requiresExtension = [manualAttributeToExtensionMapping.get(a.name)];
});

// Map attributes to tags and extensions (e.g. amp-img => amp-fx => amp-fx-collection)
tag.attrs.filter((a) => a.requiresExtension && a.requiresExtension.length > 0)
fstanis marked this conversation as resolved.
Show resolved Hide resolved
.forEach((a) => {
let attributeMapping = tagToAttributeMapping.get(tagName);
if (!attributeMapping) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This (ensuring an empty array exists as value) can be determined outside the loop rather than doing it in every iteration.

tagToAttributeMapping.set(tagName, tagToAttributeMapping.get(tagName) || [])

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this would help here as I need a reference to attributeMapping.

attributeMapping = [];
tagToAttributeMapping.set(tagName, attributeMapping);
}
attributeMapping.push(a);
});

// Map tags to bindable attributes which are named link `[text]`
const bindableAttributes = tag.attrs.filter((a) => a.name.startsWith('['))
.map((a) => a.name.substring(1, a.name.length - 1));
tagToBindAttributeMapping.set(tagName, new Set(bindableAttributes));
}
return {
extensionsMap,
tagToExtensionsMapping,
tagToAttributeMapping,
tagToBindAttributeMapping,
};
});
}

async transform(tree, params) {
if (!this.enabled) {
return;
}
const html = tree.root.firstChildByTag('html');
const head = html.firstChildByTag('head');
if (!head) return;
fstanis marked this conversation as resolved.
Show resolved Hide resolved

// Extensions which need to be imported
const extensionsToImport = new Set();
// Keep track of existing extensions imports to avoid duplicates
const existingImports = new Set();

// Some AMP components need to be detected in the head (e.g. amp-access)
await this.findExtensionsToImportInHead_(head, extensionsToImport, existingImports);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is not async (no harm in awaiting it, but still).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixe


// Most AMP components can be detected in the body
const body = html.firstChildByTag('body');
await this.findExtensionsToImportInBody_(body, extensionsToImport);

if (extensionsToImport.length === 0) {
// Nothing to do
return;
}

// We use this for adding new import elements to the header
const referenceNode = findMetaViewport(head);

// Support custom runtime URLs
const host = calculateHost(params);
for (const extensionName of extensionsToImport) {
if (!existingImports.has(extensionName)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd use if (existingImports.has(extensionName)) { continue }

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I as well

const extension = (await this.extensionSpec).extensionsMap.get(extensionName.trim());
this.log_.debug('auto importing', extensionName);
// Use the latest version by default
const version = extension.version[extension.version.length - 1];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes me feel a bit uneasy, are we 100% sure the latest is the last in the array?

const extensionImportAttribs = {
'async': '',
'src': `${host.ampUrlPrefix}/v0/${extensionName}-${version}.js`,
};
extensionImportAttribs[extension.type] = extensionName;
const extensionImport = tree.createElement('script', extensionImportAttribs);
head.insertAfter(extensionImport, referenceNode);
}
}
}

findExtensionsToImportInHead_(head, extensionsToImport, existingImports) {
let node = head.firstChild;
while (node) {
// Detect any existing extension imports
const customElement = this.getCustomElement_(node);
if (customElement) {
existingImports.add(customElement);
fstanis marked this conversation as resolved.
Show resolved Hide resolved
}
// Explicitly detect amp-access via the script tag in the header
if (node.tagName === 'script' && node.attribs['id'] === 'amp-access') {
extensionsToImport.add('amp-access');
}
node = node.nextSibling;
}
}

async findExtensionsToImportInBody_(body, extensionsToImport) {
let node = body;
const promises = [];
while (node !== null) {
if (node.tagName) {
promises.push(this.addRequiredExtensionByTag_(extensionsToImport, node));
promises.push(this.addRequiredExtensionByAttributes_(extensionsToImport, node));
}
node = node.nextNode();
}
return Promise.all(promises);
fstanis marked this conversation as resolved.
Show resolved Hide resolved
}

async addRequiredExtensionByTag_(allRequiredExtensions, node) {
fstanis marked this conversation as resolved.
Show resolved Hide resolved
const extensionSpec = (await this.extensionSpec);
// Check for required extensions by tag name
const requiredExtensions = extensionSpec.tagToExtensionsMapping.get(node.tagName);
if (requiredExtensions) {
requiredExtensions.forEach((ext) => allRequiredExtensions.add(ext));
}
// Add custom templates (e.g. amp-mustache)
if (node.tagName === 'template' && node.attribs.type) {
allRequiredExtensions.add(node.attribs.type);
}
}

async addRequiredExtensionByAttributes_(allRequiredExtensions, node) {
if (!node.tagName || !node.attribs) {
return;
}
// Look for element attributes indicating AMP components (e.g. amp-fx)
const extensionSpec = await this.extensionSpec;
const tagToAttributeMapping = extensionSpec.tagToAttributeMapping;
const attributesForTag = tagToAttributeMapping.get(node.tagName);
if (attributesForTag) {
fstanis marked this conversation as resolved.
Show resolved Hide resolved
attributesForTag.forEach((attribute) => {
if (node.attribs[attribute.name] !== undefined) {
attribute.requiresExtension.forEach((ext) => {
allRequiredExtensions.add(ext);
});
}
});
}
// Check for amp-bind attribute bindings
const tagToBindAttributeMapping = extensionSpec.tagToBindAttributeMapping;
const attributeNames = Object.keys(node.attribs);
if (attributeNames.some((a) => a.startsWith('[') || a.startsWith('data-amp-bind'))) {
allRequiredExtensions.add('amp-bind');
}
// Rewrite short-form `bindtext` to `data-amp-bind-text`
// to avoid false-positives we check for each tag only the
// supported bindable attributes (e.g. for a div only bindtext, but not bindvalue).
const ampBindAttrs = tagToBindAttributeMapping.get(node.tagName);
// true if we need to import amp-bind
let usesAmpBind = false;
for (const attributeName of attributeNames) {
if (!attributeName.startsWith(BIND_SHORT_FORM_PREFIX)) {
continue;
}
const attributeNameWithoutBindPrefix =
attributeName.substring(BIND_SHORT_FORM_PREFIX.length);

// Rename attribute from bindx to data-amp-bind-x
fstanis marked this conversation as resolved.
Show resolved Hide resolved
if (ampBindAttrs.has(attributeNameWithoutBindPrefix)) {
const newAttributeName = `data-amp-bind-${attributeNameWithoutBindPrefix}`;
fstanis marked this conversation as resolved.
Show resolved Hide resolved
node.attribs[newAttributeName] = node.attribs[attributeName];
delete node.attribs[attributeName];
usesAmpBind = true;
}
}
if (usesAmpBind) {
allRequiredExtensions.add('amp-bind');
}
}

getCustomElement_(scriptNode) {
if (scriptNode.tagName !== 'script') {
return '';
}
let customElement = scriptNode.attribs['custom-element'] ||
scriptNode.attribs['custom-template'] ||
'';
if (!customElement) {
return '';
}
customElement = customElement.toLowerCase();
if (!customElement.startsWith('amp-')) {
return '';
}
return customElement;
}
}

module.exports = AutoExtensionImporter;
18 changes: 4 additions & 14 deletions packages/optimizer/lib/transformers/RewriteAmpUrls.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
const {
AMP_CACHE_HOST,
AMP_DYNAMIC_COMPONENTS,
appendRuntimeVersion,
} = require('../AmpConstants.js');
const {findMetaViewport} = require('../HtmlDomHelper');
const {calculateHost} = require('../RuntimeHostHelper');

/**
* RewriteAmpUrls - rewrites AMP runtime URLs.
Expand Down Expand Up @@ -57,17 +57,7 @@ class RewriteAmpUrls {
const head = html.firstChildByTag('head');
if (!head) return;

let ampUrlPrefix = params.ampUrlPrefix || AMP_CACHE_HOST;
if (params.ampRuntimeVersion && !params.ampUrlPrefix) {
ampUrlPrefix = appendRuntimeVersion(ampUrlPrefix, params.ampRuntimeVersion);
}
let dynamicAmpUrlPrefix = ampUrlPrefix;
if (params.rewriteDynamicComponents === false) {
dynamicAmpUrlPrefix = AMP_CACHE_HOST;
if (params.ampRuntimeVersion) {
dynamicAmpUrlPrefix = appendRuntimeVersion(dynamicAmpUrlPrefix, params.ampRuntimeVersion);
}
}
const host = calculateHost(params);

let node = head.firstChild;
let referenceNode = findMetaViewport(head);
Expand All @@ -76,12 +66,12 @@ class RewriteAmpUrls {
if (node.tagName === 'script' && this._usesAmpCacheUrl(node.attribs.src)) {
const isDynamicComponent = this._isDynamicComponent(node);
node.attribs.src = this._replaceUrl(node.attribs.src,
isDynamicComponent ? dynamicAmpUrlPrefix : ampUrlPrefix);
isDynamicComponent ? host.dynamicAmpUrlPrefix : host.ampUrlPrefix);
referenceNode = this._addPreload(tree, head, referenceNode, node.attribs.src, 'script');
} else if (node.tagName === 'link' &&
node.attribs.rel === 'stylesheet' &&
this._usesAmpCacheUrl(node.attribs.href)) {
node.attribs.href = this._replaceUrl(node.attribs.href, ampUrlPrefix);
node.attribs.href = this._replaceUrl(node.attribs.href, host.ampUrlPrefix);
referenceNode = this._addPreload(tree, head, referenceNode, node.attribs.href, 'style');
}
node = node.nextSibling;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!DOCTYPE html><html ⚡=""><head><meta charset="utf-8"><title>My AMP Page</title><link rel="canonical" href="self.html"><meta name="viewport" content="width=device-width,minimum-scale=1"><script async="" src="https://cdn.ampproject.org/v0/amp-bind-0.1.js" custom-element="amp-bind"></script><style amp-boilerplate="">body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript><script async="" src="https://cdn.ampproject.org/v0.js"></script></head><body><div data-amp-bind-text="greeting"></div><button on="tap:AMP.setState({ greeting: 'hello world' })">Say hello</button></body></html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html ⚡>
<head>
<meta charset="utf-8">
<title>My AMP Page</title>
<link rel="canonical" href="self.html" />
<meta name="viewport" content="width=device-width,minimum-scale=1">
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
<script async src="https://cdn.ampproject.org/v0.js"></script>
</head>
<body>
<div bindtext="greeting"></div>
<button on="tap:AMP.setState({ greeting: 'hello world' })">Say hello</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!DOCTYPE html><html ⚡=""><head><meta charset="utf-8"><title>My AMP Page</title><link rel="canonical" href="self.html"><meta name="viewport" content="width=device-width,minimum-scale=1"><script async="" src="https://cdn.ampproject.org/v0/amp-bind-0.1.js" custom-element="amp-bind"></script><style amp-boilerplate="">body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript><script async="" src="https://cdn.ampproject.org/v0.js"></script></head><body><div data-amp-bind-text="greeting"></div><button on="tap:AMP.setState({ greeting: 'hello world' })">Say hello</button></body></html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html ⚡>
<head>
<meta charset="utf-8">
<title>My AMP Page</title>
<link rel="canonical" href="self.html" />
<meta name="viewport" content="width=device-width,minimum-scale=1">
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
<script async src="https://cdn.ampproject.org/v0.js"></script>
</head>
<body>
<div data-amp-bind-text="greeting"></div>
<button on="tap:AMP.setState({ greeting: 'hello world' })">Say hello</button>
</body>
</html>
Loading