/**
* 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 {insertText, createElement, hasAttribute, firstChildByTag} = require('../NodeUtils');
const safeParser = require('postcss-safe-parser');
const postcss = require('postcss');
const cssnano = require('cssnano-simple');
const allowedKeyframeProps = new Set([
'animation-timing-function',
'offset-distance',
'opacity',
'visibility',
'transform',
'-webkit-transform',
'-moz-transform',
'-o-transform',
'-ms-transform',
]);
/**
* SeparateKeyframes - moves keyframes, media, and support from amp-custom
* to amp-keyframes.
*
* This transformer supports the following options:
*
* - `minify [Boolean]`: compresses the CSS. The default is `true`.
*/
class SeparateKeyframes {
constructor(config) {
this.log_ = config.log.tag('SeparateKeyframes');
this.minify = config.minify !== false;
}
async transform(tree) {
const html = firstChildByTag(tree, 'html');
if (!html) return;
const head = firstChildByTag(html, 'head');
if (!head) return;
const body = firstChildByTag(html, 'body') || head;
if (this.isAmpStory(body)) {
return;
}
let stylesCustomTag;
let stylesKeyframesTag;
// Get style[amp-custom] and remove style[amp-keyframes]
head.children = head.children.filter((tag) => {
if (tag.tagName !== 'style') return true;
if (!stylesKeyframesTag && hasAttribute(tag, 'amp-keyframes')) {
stylesKeyframesTag = tag;
return false;
}
if (!stylesCustomTag && hasAttribute(tag, 'amp-custom')) {
stylesCustomTag = tag;
}
return true;
});
const extraPlugins = this.minify ? [cssnano] : [];
// If no custom styles, there's nothing to do
if (!stylesCustomTag) return;
let stylesText = stylesCustomTag.children[0];
if (!stylesText || !stylesText.data) return;
stylesText = stylesText.data;
// initialize an empty keyframes tree
const keyframesTree = postcss.parse('');
const isInvalidKeyframe = (keyframe) => {
let invalidProperty;
for (const frame of keyframe.nodes) {
for (const declaration of frame.nodes) {
if (!allowedKeyframeProps.has(declaration.prop)) {
invalidProperty = declaration.prop;
break;
}
}
if (invalidProperty) break;
}
return invalidProperty;
};
const keyframesPlugin = () => {
const logInvalid_ = this.logInvalid.bind(this);
return {
postcssPlugin: 'postcss-amp-keyframes-mover',
Once(root) {
root.nodes = root.nodes.filter((rule) => {
if (rule.name === 'keyframes') {
// We can't move a keyframe with an invalid property
// or else the style[amp-keyframes] is invalid
const invalidProperty = isInvalidKeyframe(rule);
if (invalidProperty) {
logInvalid_(rule.name, invalidProperty);
return true;
}
keyframesTree.nodes.push(rule);
return false;
}
// if rule has any keyframes duplicate rule and move just
// the keyframes
if (rule.name === 'media' || rule.name === 'supports') {
const copiedRule = Object.assign({}, rule, {nodes: []});
rule.nodes = rule.nodes.filter((rule) => {
if (rule.name !== 'keyframes') return true;
const invalidProperty = isInvalidKeyframe(rule);
if (invalidProperty) {
logInvalid_(rule.name, invalidProperty);
return true;
}
copiedRule.nodes.push(rule);
});
if (copiedRule.nodes.length) {
keyframesTree.nodes.push(copiedRule);
}
// if no remaining rules remove it
return rule.nodes.length;
}
return true;
});
},
};
};
keyframesPlugin.postcss = true;
const {css: cssResult} = await postcss([...extraPlugins, keyframesPlugin])
.process(stylesText, {
from: undefined,
parser: safeParser,
})
.catch((err) => {
this.log_.warn(`Failed to process CSS`, err.message);
return {css: stylesText};
});
// if no rules moved nothing to do
if (keyframesTree.nodes.length === 0) {
// re-serialize to compress the CSS
stylesCustomTag.children[0].data = cssResult;
return;
}
if (!stylesKeyframesTag) {
// Check body for keyframes tag, removing it if found
body.children = body.children.filter((tag) => {
if (tag.tagName === 'style' && hasAttribute(tag, 'amp-keyframes')) {
stylesKeyframesTag = tag;
return false;
}
return true;
});
if (!stylesKeyframesTag) {
stylesKeyframesTag = createElement('style', {'amp-keyframes': ''});
}
}
// Insert keyframes styles to Node
const keyframesTextNode = stylesKeyframesTag.children[0];
const currentKeyframesTree = postcss.parse((keyframesTextNode && keyframesTextNode.data) || '');
currentKeyframesTree.nodes = keyframesTree.nodes.concat(currentKeyframesTree.nodes);
let keyframesText = '';
postcss.stringify(currentKeyframesTree, (part) => {
keyframesText += part;
});
// if we have extra plugins make sure process the keyframes CSS with them
if (extraPlugins.length > 0) {
const cssResult = await postcss(extraPlugins).process(keyframesText, {
from: undefined,
parser: safeParser,
});
keyframesText = cssResult.css;
}
if (!keyframesTextNode) {
insertText(stylesKeyframesTag, keyframesText);
} else {
keyframesTextNode.data = keyframesText;
}
// Add keyframes tag to end of body
body.children.push(stylesKeyframesTag);
// Update stylesCustomTag with filtered styles
stylesCustomTag.children[0].data = cssResult;
}
logInvalid(name, property) {
this.log_.warn(
`Found invalid keyframe property '${property}' in '${name}' not moving to style[amp-keyframes]`
);
}
isAmpStory(body) {
return body.children.some((child) => child.tagName === 'amp-story');
}
}
module.exports = SeparateKeyframes;