/**
* 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 {
nextNode,
insertAfter,
createElement,
firstChildByTag,
hasAttribute,
} = require('../NodeUtils');
const {findMetaViewport, findRuntimeScript} = require('../HtmlDomHelper');
const {AMP_FORMATS, DEFAULT_AMP_CACHE_HOST} = require('../AmpConstants');
const BIND_SHORT_FORM_PREFIX = 'bind';
const AMP_BIND_DATA_ATTRIBUTE_PREFIX = 'data-amp-bind-';
const DEFAULT_FORMAT = 'AMP';
// 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([['lightbox', 'amp-lightbox-gallery']]);
const manualExtensions = Array.from(manualAttributeToExtensionMapping.values());
/**
* 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 specify attribute to extension mappings if this information is not available in the
* validation rules.
* - manually 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 accidentally
* rewriting non-AMP attributes, the transformer uses the AMP validation rules to only rename bindable
* attributes as specified in the validation rules.
*
* This transformer supports the following option:
*
* - `format: [AMP|AMP4EMAIL|AMP4ADS]` - specifies the AMP format. Defaults to `AMP`.
* - `autoExtensionImport: [true|false]` - set to `false` to disable the auto extension import. Default to `true`.
*/
class AutoExtensionImporter {
constructor(config) {
this.enabled = config.autoExtensionImport !== false;
this.format = config.format || DEFAULT_FORMAT;
this.log_ = config.log.tag('AutoExtensionImporter');
this.experimentBindAttributeEnabled = config.experimentBindAttribute === true;
this.extensionVersions = config.extensionVersions || {};
}
/**
* @private
*/
createExtensionsSpec(params) {
const rules = params.validatorRules;
// Map extension names to info required for generating the extension imports
const extensionsMap = new Map();
for (const ext of rules.extensions) {
if (ext.htmlFormat.includes(this.format)) {
extensionsMap.set(ext.name, {
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.getTagsForFormat(this.format)) {
const tagName = tag.tagName.toLowerCase();
// Map amp tags to their required extension(s)
if (tagName.startsWith('amp-')) {
// HACK: some tags define multiple validation rules for attribute based imports
// e.g. amp-carousel, amp-carousel[lightbox]
// these are handled differently, so we filter them out here
let requiresExtension = tag.requiresExtension || [];
requiresExtension = requiresExtension.filter((ext) => !manualExtensions.includes(ext));
tagToExtensionsMapping.set(tagName, requiresExtension);
}
// Collects all bindable attributes
const bindableAttributes = new Set();
// Process the tag specific attributes
for (const attribute of tag.attrs) {
// Hack: fix missing attribute dependencies (e.g. amp-img => lightbox => amp-lightbox-gallery)
if (manualAttributeToExtensionMapping.has(attribute.name)) {
attribute.requiresExtension = [manualAttributeToExtensionMapping.get(attribute.name)];
}
// Map attributes to tags and extensions (e.g. amp-img => amp-fx => amp-fx-collection)
if (attribute.requiresExtension && attribute.requiresExtension.length > 0) {
const attributeMapping = tagToAttributeMapping.get(tagName) || [];
attributeMapping.push(attribute);
tagToAttributeMapping.set(tagName, attributeMapping);
}
// Maps tags to bindable attributes which are named `[text]`
if (attribute.name.startsWith('[')) {
bindableAttributes.add(attribute.name.substring(1, attribute.name.length - 1));
}
}
tagToBindAttributeMapping.set(tagName, bindableAttributes);
}
return {
extensionsMap,
tagToExtensionsMapping,
tagToAttributeMapping,
tagToBindAttributeMapping,
};
}
async transform(root, params) {
if (!this.enabled) {
return;
}
if (!params.validatorRules) {
this.log_.error('Missing validation rules, cannot auto import extensions');
return;
}
if (!this.componentVersions) {
this.componentVersions = {};
for (const component of params.componentVersions) {
// only add latestVersion if defined, some components might be listed, but don't define a latest version
if (component.latestVersion) {
this.componentVersions[component.name] = component.latestVersion;
}
}
}
if (!this.extensionSpec_) {
this.extensionSpec_ = this.createExtensionsSpec(params);
}
if (!AMP_FORMATS.includes(this.format)) {
this.log_.error('Unsupported AMPHTML format', this.format);
return;
}
const html = firstChildByTag(root, 'html');
if (!html) return;
const head = firstChildByTag(html, 'head');
if (!head) return;
const body = firstChildByTag(html, 'body');
if (!body) return;
// 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)
this.findExistingExtensionsAndExtensionsToImportInHead_(
head,
extensionsToImport,
existingImports
);
// Most AMP components can be detected in the 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
let referenceNode = findRuntimeScript(head);
if (!referenceNode) {
referenceNode = findMetaViewport(head);
}
// Use cdn.ampproject.org as default, RewriteUrlTransformer will change this in case of self-hosting
const host = DEFAULT_AMP_CACHE_HOST;
for (const extensionName of extensionsToImport) {
if (existingImports.has(extensionName)) {
continue;
}
const extension = this.extensionSpec_.extensionsMap.get(extensionName.trim());
this.log_.debug('auto importing', extensionName);
// Use the latest version by default
let version = this.calculateVersion(extension, extensionName);
const extensionImportAttribs = {
async: '',
src: `${host}/v0/${extensionName}-${version}.js`,
};
extensionImportAttribs[extension.type] = extensionName;
const extensionImport = createElement('script', extensionImportAttribs);
insertAfter(head, extensionImport, referenceNode);
referenceNode = extensionImport;
}
}
/**
* @private
*/
calculateVersion(extension, extensionName) {
// Let user override default
const customVersion = this.extensionVersions[extensionName];
if (customVersion) {
this.log_.debug('using custom version for', extensionName, customVersion);
return customVersion;
}
// Get latest non-experimental prod version (as calculated based on experimental flag)
const latestProdVersion = this.componentVersions[extensionName];
// Also get latest version supported by the validator
const latestValidatorVersion = extension.version[extension.version.length - 1];
// Verify latest version based on validator support
if (latestValidatorVersion < latestProdVersion) {
return latestValidatorVersion;
}
return latestProdVersion;
}
/**
* @private
*/
findExistingExtensionsAndExtensionsToImportInHead_(head, extensionsToImport, existingImports) {
let node = head;
while (node) {
// Detect any existing extension imports
const customElement = this.getCustomElement_(node);
if (customElement) {
existingImports.add(customElement);
}
// Explicitly detect amp-access via the script tag in the header to be able to handle amp-access extensions
else if (node.tagName === 'script' && node.attribs['id'] === 'amp-access') {
extensionsToImport.add('amp-access');
extensionsToImport.add('amp-analytics');
const jsonData = this.getJson(node);
if (jsonData.vendor === 'laterpay') {
extensionsToImport.add('amp-access-laterpay');
}
// Explicitly detect amp-subscriptions via the script tag in the header to be able to handle amp-subscriptions extensions
} else if (node.tagName === 'script' && node.attribs['id'] === 'amp-subscriptions') {
extensionsToImport.add('amp-subscriptions');
extensionsToImport.add('amp-analytics');
const jsonData = this.getJson(node);
if (jsonData.services && jsonData.services.length) {
for (const service of jsonData.services) {
if (service.serviceId === 'subscribe.google.com') {
extensionsToImport.add('amp-subscriptions-google');
}
}
}
}
node = nextNode(node);
}
}
getJson(node) {
for (const child of node.children || []) {
if (!child.data) {
continue;
}
try {
return JSON.parse(child.data);
} catch (error) {
this.log_.error('Could not parse JSON in