UNPKG

amphtml-autoscript

Version:

Imports required AMP HTML custom-elements into source files.

280 lines (255 loc) 9.54 kB
const amphtmlValidator = require('amphtml-validator'); const through = require('through2'); const fetch = require('node-fetch'); const fs = require('fs'); const REGEX_EXTENSION_DIR = /extensions\/(amp-[^\/]+)\/([0-9]+\.[0-9]+)$/; const GITHUB_AMPHTML_TREE_URL = 'https://api.github.com/repos/ampproject/amphtml/git/trees/master?recursive=1'; const PLUGIN_NAME = 'amphtml-autoscript'; const PluginError = require('plugin-error'); const AMP_BASE_URL_ELEMENT = '<script async src="https://cdn.ampproject.org/v0.js"></script>'; const COMPONENTS_MAP_PATH = __dirname + '/components.json'; // This module has two modes of operation: // - Placeholder: The module searches for a string, e.g. ${ampjs}. // This is replaced with the script tags. // - Header insertion: No placeholder is sought. Script tags are placed at the // bottom of the <head/> element. const MODES = { PLACEHOLDER: 1, HEADER_INSERTION: 2 }; module.exports.modes = MODES; // This placeholder should be put in the AMP HTML file in the desired location // for substitution withe the AMP base <script> tag and any identified // custom-elements that are required. const DEFAULT_AMP_PLACEHOLDER = '${ampjs}'; const DEFAULT_INSERTION_MODE = MODES.PLACEHOLDER; // Hold a map from component to latest version number, e.g. amp-bind -> 0.1 let VERSION_MAP = null; // Some scripts are not imported with custom-element defined, but instead us // custom-template. const AMP_SCRIPT_TYPE_MAP = { 'amp-mustache': 'custom-template' }; /** * Imports the required AMP custom-element script tags into an AMP document. * * @param {Object} opt_options Configurations options, currently: * placeholder: Overrides the default placeholder. * mode: Overrides the default mode (MODES.PLACEHOLDER); * @return {!Transform} The created stream.Transform object. */ function create(opt_options) { function runInclude(file, encoding, callback) { if (file.isNull()) { return callback(null, file); } if (file.isStream()) { this.emit('error', new PluginError(PLUGIN_NAME, 'Streams not supported!')); } if (file.isBuffer()) { addIncludesToFile(file, opt_options).then((file) => { return callback(null, file); }) .catch(() => { return callback(null, file); }); } } var rv = function() { return through.obj(runInclude); }; return rv; }; /** * Identifies missing AMP custom-elementscript tags in an AMP HTML file and * adds them. * * @param {!Vinyl} file The file to add script tags to. * @param {Object} opt_options See {@code create}. * @return {!Vinyl} The modified file. */ async function addIncludesToFile(file, opt_options) { const overrideMap = await readComponentsMap('./amp-versions.json'); const html = file.contents.toString(); const newHtml = await addIncludesToHtml(html, overrideMap, opt_options); file.contents = new Buffer.from(newHtml); return file; }; /** * Identifies missing AMP custom-element script tags in an AMP HTML test and * adds them. * * This is achieved by: * 1. Running the AMP validator and filtering for errors for missing extensions. * 2. Creating <script> elements using versions taken from GitHub directory * listings. * * @param {!Vinyl} file The file to add script tags to. * @param {Object} overrideMap Lookup of component versions for this project. * @param {Object} opt_options See {@code create}. * @return {!Vinyl} The modified file. */ async function addIncludesToHtml(html, overrideMap, opt_options) { let instance = await amphtmlValidator.getInstance(); const options = opt_options || {}; if (options.updateComponentsMap) { await updateComponentMap(); } const versionMap = await readComponentsMap(COMPONENTS_MAP_PATH); const result = instance.validateString(html); const placeholder = options.placeholder || DEFAULT_AMP_PLACEHOLDER; const mode = options.mode || DEFAULT_INSERTION_MODE; // It is necessary to escape the placeholder as it is used in a RegExp object // for example ${ampjs} -> \\$\\{ampjs\\}. // Furthermore, the regular expression is defined to also match any // preceding whitespace too, so that inserted tags can be indented to match. const escapedPlaceholder = new RegExp('([^\\S\\r\\n]*)' + escapeRegex(placeholder)); var missingScriptUrls = new Set(); if (result.status === 'FAIL') { // Determine whether the base AMP script element is missing. for (err of result.errors) { if (err.code === 'MANDATORY_TAG_MISSING' && err.params && err.params[0] === 'amphtml engine v0.js script') { missingScriptUrls.add(AMP_BASE_URL_ELEMENT); break; } } // Filter for only those errors indicating a missing script tag. const tagErrors = result.errors .filter(err => { return (err.code === 'MISSING_REQUIRED_EXTENSION' || err.code === 'ATTR_MISSING_REQUIRED_EXTENSION')}); for (let tagError of tagErrors) { const tagName = tagError.params[1]; if (overrideMap[tagName]) { var tagVersion = overrideMap[tagName]; } else if (options.forceLatest) { var tagVersion = 'latest'; } else if (versionMap[tagName]) { var tagVersion = versionMap[tagName]; } if (!tagVersion) { throw Error('Unknown AMP Component ' + tagName); } missingScriptUrls.add(createAmpCustomElementTag(tagName, tagVersion)); } } if (missingScriptUrls.size) { if (mode === MODES.PLACEHOLDER) { return addScriptUrlsByPlaceHolder(html, missingScriptUrls, escapedPlaceholder); } else { return addScriptUrlsByHeaderInsertion(html, missingScriptUrls); } } return html; }; /** * Replaces a string placeholder with <script> tags. * * @param {string} html The HTML, containing the placeholder. * @param {Array.<string>} missingScriptUrls The list of <script> elements. * @param {string} placeholder The string to replace. * @return {string} The substituted HTML. */ function addScriptUrlsByPlaceHolder(html, missingScriptUrls, placeholder) { // Add the matched whitespace to the replacement specifier, to ensure // inserted script tags are indented to the same level as the placeholder. const indentedUrls = Array.from(missingScriptUrls).map((u) => '$1' + u); return html.replace(placeholder, indentedUrls.join('\n')); } /** * Inserts <script> tags into the <head> of an HTML document. * * @param {string} html The HTML document. * @param {Array.<string>} missingScriptUrls The list of <script> elements. * @return {string} The substituted HTML. */ function addScriptUrlsByHeaderInsertion(html, missingScriptUrls) { const escapedPlaceholder = new RegExp('([^\\S\\r\\n]*)' + escapeRegex('</head>'), 'i'); const indentedUrls = Array.from(missingScriptUrls).map((u) => ' ' + u); indentedUrls.push('</head>'); whitespaceUrls = indentedUrls.map((u) => '$1' + u); return html.replace(escapedPlaceholder, whitespaceUrls.join('\n')); } /** * Create a map from component to version number, based on GitHub directory * structure. */ async function updateComponentMap() { const response = await fetch(GITHUB_AMPHTML_TREE_URL); const data = await response.json(); const pairs = data.tree.map((item) => item.path.match(REGEX_EXTENSION_DIR)) .filter((match) => match && !match[1].endsWith('impl')) .map((match) => [match[1], match[2]]); const versionMap = {}; pairs.forEach((pair) => { if (!versionMap[pair[0]] || versionMap[pair[0]] < pair[1]) { versionMap[pair[0]] = pair[1]; } }); writeComponentsMap(COMPONENTS_MAP_PATH, versionMap); }; /** * Writes a component map to the file system. * * @param {string} path The path to the file to write to. * @param {Object} componentsMap The map of components to versions. * @return {number} The length of data written. */ function writeComponentsMap(path, componentsMap) { return new Promise((resolve, reject) => { const data = JSON.stringify(componentsMap); fs.writeFile(path, data, (err) => { if (err) { return reject(err); } resolve(data.length); }); }); } /** * Reads a component map from the file system. * * @param {string} path The path to the file to write to. * @return {Object} The map of components to versions. */ async function readComponentsMap(path) { return new Promise((resolve, reject) => { fs.readFile(path, (err, data) => { if (err) { return resolve({}); } resolve(JSON.parse(data)); }); }); } /** * Escapes a string such that it can be safely used in a regular expression. * * @param {string} text The string to escape. * @return {string} The escaped string. */ function escapeRegex(text) { return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); } /** * Builds a script tag for a custom element. * * @param {string} tagName The custom element to include. * @param {number} version The version number to include. * @return {string} The <script> tag. */ function createAmpCustomElementTag(tagName, version) { const scriptType = AMP_SCRIPT_TYPE_MAP[tagName] || 'custom-element'; return `<script async ${scriptType}="${tagName}" ` + `src="https://cdn.ampproject.org/v0/${tagName}-${version}.js"></script>`; } module.exports.addIncludesToFile = addIncludesToFile; module.exports.addIncludesToHtml = addIncludesToHtml; module.exports.create = create;