/**
* Copyright 2017 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 {
createElement,
hasAttribute,
firstChildByTag,
insertAfter,
insertBefore,
remove,
} = require('../NodeUtils');
const {AMP_CACHE_HOSTS} = require('../AmpConstants.js');
const {findMetaViewport} = require('../HtmlDomHelper');
const {calculateHost} = require('../RuntimeHostHelper');
/**
* RewriteAmpUrls - rewrites AMP runtime URLs.
*
* This transformer supports four parameters:
*
* * `ampRuntimeVersion`: specifies a
* [specific version](https://github.com/ampproject/amp-toolbox/tree/main/runtime-version)
* version of the AMP runtime. For example: `ampRuntimeVersion:
* "001515617716922"` will result in AMP runtime URLs being re-written
* from `https://cdn.ampproject.org/v0.js` to
* `https://cdn.ampproject.org/rtv/001515617716922/v0.js`.
*
* * `ampUrlPrefix`: specifies an URL prefix for AMP runtime
* URLs. For example: `ampUrlPrefix: "/amp"` will result in AMP runtime
* URLs being re-written from `https://cdn.ampproject.org/v0.js` to
* `/amp/v0.js`. This option is experimental and not recommended.
*
* * `geoApiUrl`: specifies amp-geo API URL to use as a fallback when
* amp-geo-0.1.js is served unpatched, i.e. when
* {{AMP_ISO_COUNTRY_HOTPATCH}} is not replaced dynamically.
*
* * `lts`: Use long-term stable URLs. This option is not compatible with
* `ampRuntimeVersion` or `ampUrlPrefix`; an error will be thrown if
* these options are included together. Similarly, the `geoApiUrl`
* option is ineffective with the lts flag, but will simply be ignored
* rather than throwing an error.
*
* * `esmModulesEnabled`: Enables the smaller ESM module version of AMP runtime
* and components.
*
* All parameters are optional. If no option is provided, runtime URLs won't be
* re-written. You can combine `ampRuntimeVersion` and `ampUrlPrefix` to
* rewrite AMP runtime URLs to versioned URLs on a different origin.
*
* This transformer also adds a preload header for the AMP runtime (v0.js) to trigger HTTP/2
* push for CDNs (see https://www.w3.org/TR/preload/#server-push-(http/2)).
*/
class RewriteAmpUrls {
constructor(config) {
this.esmModulesEnabled = config.esmModulesEnabled !== false;
this.log = config.log;
}
transform(root, params) {
const html = firstChildByTag(root, 'html');
const head = firstChildByTag(html, 'head');
if (!head) return;
const host = calculateHost(params);
let node = head.firstChild;
let referenceNode = findMetaViewport(head);
const esm = this.esmModulesEnabled || params.esmModulesEnabled;
params.esmModulesEnabled = esm;
const preloadEnabled = !hasAttribute(html, 'i-amphtml-no-boilerplate');
const preloads = [];
while (node) {
if (node.tagName === 'script' && this._usesAmpCacheUrl(node.attribs.src)) {
node.attribs.src = this._replaceUrl(node.attribs.src, host);
if (esm) {
const preload = this._addEsm(node, preloadEnabled);
if (preloadEnabled && preload) {
preloads.push(preload);
}
} else if (preloadEnabled) {
preloads.push(this._createPreload(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, host);
if (preloadEnabled) {
preloads.push(this._createPreload(node.attribs.href, 'style'));
}
} else if (
node.tagName === 'link' &&
node.attribs.rel === 'preload' &&
this._usesAmpCacheUrl(node.attribs.href)
) {
if (esm && this._shouldPreload(node.attribs.href)) {
// only preload mjs runtime in esm mode
remove(node);
} else {
node.attribs.href = this._replaceUrl(node.attribs.href, host);
}
}
node = node.nextSibling;
}
// process preloads later to avoid accidentally rewriting the URL
for (const preload of preloads) {
if (preload) {
insertAfter(head, preload, referenceNode);
}
}
// runtime-host and amp-geo-api meta tags should appear before the first script
if (!this._usesAmpCacheUrl(host) && !params.lts) {
try {
const url = new URL(host);
this._addMeta(head, 'runtime-host', url.origin + url.pathname);
} catch (e) {
this.log.warn('ampUrlPrefix must be an absolute URL');
}
}
if (params.geoApiUrl && !params.lts) {
this._addMeta(head, 'amp-geo-api', params.geoApiUrl);
}
}
_usesAmpCacheUrl(url) {
if (!url) {
return false;
}
// check if url starts with one of string array
return AMP_CACHE_HOSTS.some((host) => url.startsWith(host));
}
_replaceUrl(url, host) {
const existingHost = AMP_CACHE_HOSTS.find((ampCacheHost) => url.startsWith(ampCacheHost));
return host + url.substring(existingHost.length);
}
_addEsm(scriptNode, preloadEnabled) {
let result = null;
const esmScriptUrl = scriptNode.attribs.src.replace(/\.js$/, '.mjs');
if (preloadEnabled && this._shouldPreload(scriptNode.attribs.src)) {
const preload = createElement('link', {
as: 'script',
crossorigin: 'anonymous',
href: esmScriptUrl,
rel: 'modulepreload',
});
result = preload;
}
const nomoduleNode = createElement('script', {
async: '',
nomodule: '',
src: scriptNode.attribs.src,
crossorigin: 'anonymous',
});
const customElement = scriptNode.attribs['custom-element'];
if (customElement) {
nomoduleNode.attribs['custom-element'] = customElement;
}
const customTemplate = scriptNode.attribs['custom-template'];
if (customTemplate) {
nomoduleNode.attribs['custom-template'] = customTemplate;
}
insertBefore(scriptNode.parent, nomoduleNode, scriptNode);
scriptNode.attribs.type = 'module';
// Without crossorigin=anonymous browser loads the script twice because
// of preload.
scriptNode.attribs.crossorigin = 'anonymous';
scriptNode.attribs.src = esmScriptUrl;
return result;
}
_createPreload(href, type) {
if (!this._shouldPreload(href)) {
return null;
}
return createElement('link', {
rel: 'preload',
href: href,
as: type,
});
}
_shouldPreload(href) {
return href.endsWith('v0.js') || href.endsWith('v0.css');
}
_addMeta(head, name, content) {
const meta = createElement('meta', {name, content});
insertBefore(head, meta, firstChildByTag(head, 'script'));
}
isAbsoluteUrl_(url) {
try {
new URL(url);
return true;
} catch (ex) {
return false;
}
}
}
module.exports = RewriteAmpUrls;