/**
* 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 {minify} = require('terser');
const {remove} = require('../NodeUtils');
const normalizeWhitespace = require('normalize-html-whitespace');
const htmlEscape = require('../htmlEscape');
// Ignore comments of the form by default (used by Next.js)
const COMMENT_DEFAULT_IGNORE = /^\s*__[a-bA-Z0-9_-]+__\s*$/;
/**
* MinifyHtml - minifies files size by:
*
* - minifying inline JSON
* - minifying inline amp-script using https://www.npmjs.com/package/terser
* - collapsing whitespace outside of pre, script, style and area.
* - removing comments
*
* This transformer supports the following options:
*
* - `minify [Boolean]`: Enables HTML minification. The default is `true`.
*/
class MinifyHtml {
constructor(config) {
this.opts = {
minify: config.minify !== false,
minifyAmpScript: true,
minifyJSON: true,
collapseWhitespace: true,
removeComments: true,
canCollapseWhitespace: true,
inBody: false,
commentIgnorePattern: COMMENT_DEFAULT_IGNORE,
};
this.log = config.log.tag('MinifyHtml');
}
async transform(tree) {
if (!this.opts.minify) {
return;
}
// store nodes for later deletion to avoid changing the tree structure
// while iterating the DOM
const nodesToRemove = [];
// recursively walk through all nodes and minify if possible
await this.minifyNode(tree, this.opts, nodesToRemove);
for (const node of nodesToRemove) {
remove(node);
}
}
async minifyNode(node, opts, nodesToRemove) {
if (node.type === 'text') {
this.minifyTextNode(node, opts, nodesToRemove);
} else if (node.type === 'comment') {
this.minifyCommentNode(node, opts, nodesToRemove);
} else if (node.tagName === 'script') {
await this.minifyScriptNode(node, opts);
}
// update options based on the current node
const childOpts = Object.assign({}, opts);
if (opts.canCollapseWhitespace && !this.canCollapseWhitespace(node.tagName)) {
childOpts.canCollapseWhitespace = false;
}
if (node.tagName === 'head' || node.tagName === 'html') {
childOpts.inBody = false;
} else if (node.tagName === 'body') {
childOpts.inBody = true;
}
// minify all child nodes
const childPromises = [];
for (const child of node.children || []) {
childPromises.push(this.minifyNode(child, childOpts, nodesToRemove));
}
return Promise.all(childPromises);
}
minifyTextNode(node, opts, nodesToRemove) {
if (!node.data || !opts.collapseWhitespace) {
return;
}
if (opts.canCollapseWhitespace) {
node.data = normalizeWhitespace(node.data);
}
if (!opts.inBody) {
node.data = node.data.trim();
}
// remove empty nodes
if (node.data.length === 0) {
nodesToRemove.push(node);
}
}
minifyCommentNode(node, opts, nodesToRemove) {
if (!node.data || !opts.removeComments) {
return;
}
if (opts.commentIgnorePattern.test(node.data)) {
return;
}
nodesToRemove.push(node);
}
async minifyScriptNode(node, opts) {
const isJson = this.isJson(node);
const isAmpScript = !isJson && this.isInlineAmpScript(node);
for (const child of node.children || []) {
if (!child.data) {
continue;
}
if (isJson && opts.minifyJSON) {
this.minifyJson(child);
} else if (isAmpScript && opts.minifyAmpScript) {
await this.minifyAmpScript(child);
}
}
}
async minifyAmpScript(child) {
try {
const result = await minify(child.data, {});
if (result.error) {
this.log.warn(
'Could not minify inline amp-script',
child.data,
`${result.error.name}: ${result.error.message}`
);
return;
}
child.data = result.code;
} catch (e) {
this.log.warn('Failed minifying inline amp-script', e);
}
}
minifyJson(child) {
try {
let jsonString = JSON.stringify(JSON.parse(child.data), null, '');
jsonString = htmlEscape(jsonString);
child.data = jsonString;
} catch (e) {
// log invalid JSON, but don't fail
this.log.warn('Invalid JSON', child.data);
}
}
isInlineAmpScript(node) {
return (
node.attribs && node.attribs.type === 'text/plain' && node.attribs.target === 'amp-script'
);
}
isJson(node) {
return (
node.attribs &&
(node.attribs.type === 'application/json' || node.attribs.type === 'application/ld+json')
);
}
canCollapseWhitespace(tagName) {
return (
'script' !== tagName && 'style' !== tagName && 'pre' !== tagName && 'textarea' !== tagName
);
}
canTrimWhitespace(tagName) {
return tagName !== 'pre' && tagName !== 'textarea';
}
}
module.exports = MinifyHtml;