UNPKG

postcss-critical-split

Version:

A PostCSS plugin that takes existing CSS files and splits out the annotated critical styles into a seperate file.

572 lines (456 loc) 13.8 kB
'use strict'; var output_types = { 'INPUT_CSS': 'input', 'CRITICAL_CSS': 'critical', 'REST_CSS': 'rest' }, path = require('path'), fs = require('fs'), merge = require('merge'), postcss = require('postcss'), userOptions = null, criticalActive = false, defaults = { 'startTag': 'critical:start', 'endTag': 'critical:end', 'blockTag': 'critical', 'suffix': '-critical', 'output': output_types.CRITICAL_CSS, 'save': false, 'modules': null, 'separator': ':', 'debug': false }, stats = null; function CriticalSplit(newOptions) { newOptions = newOptions || {}; return function(originalCss, result) { if (applyUserOptions(newOptions)) { setupStats(); performTransform(originalCss, result); if (userOptions.debug === true) { processStats(); } } return result; }; } function timestamp() { return new Date().getTime(); } function setupStats() { stats = {}; stats.startTime = 0; stats.endTime = 0; stats.processTime = 0; stats.rules = 0; stats.criticals = 0; stats.criticalPercentage = 0; stats.loops = 0; stats.clones = 0; stats.emtpyClones = 0; stats.compares = 0; stats.appends = 0; stats.getParents = 0; stats.parentRequest = 0; } function processStats() { var key = null, value = null, message = ''; stats.processTime = stats.endTime - stats.startTime; message += '----- postcss-critical-split debug info ---------\n'; for (key in stats) { /* istanbul ignore else */ if (stats.hasOwnProperty(key)) { value = stats[key]; switch(key) { case 'processTime': value = value + 'ms'; break; case 'startTime': case 'endTime': value = null; break; case 'criticalPercentage': value = (stats.criticals / stats.rules * 100).toFixed(2) + '%'; break; } if (value !== null) { message += key + ': ' + value + '\n'; } } } message += '-------------------------------------------------\n'; console.log(message); } function performTransform(inputCss, result) { var originalCss = clone(inputCss), criticalCss = postcss.root(), absolutePath = null, directoryPath = null, nonCriticalFilename = null, criticalFilename = null; getAllCriticals(originalCss, criticalCss); cleanUp(originalCss); cleanUp(criticalCss); if (userOptions.save === true) { //console.log('warning'); console.warn('postcss-critical-split: The save feature has been deprecated and should be avoided. This feature will be removed in v3.0.0. Read more about it here: https://github.com/mrnocreativity/postcss-critical-split/issues/3'); absolutePath = originalCss.source.input.file, directoryPath = path.dirname(absolutePath), nonCriticalFilename = path.basename(absolutePath), criticalFilename = createCriticalFilename(nonCriticalFilename); saveCssFile(path.join(directoryPath, nonCriticalFilename), originalCss); saveCssFile(path.join(directoryPath, criticalFilename), criticalCss); } switch(userOptions.output) { case output_types.INPUT_CSS: result.root = inputCss; break; case output_types.CRITICAL_CSS: result.root = criticalCss; break; case output_types.REST_CSS: result.root = originalCss; break; } } function saveCssFile(filepath, cssRoot) { if (cssRoot.nodes.length > 0) { fs.writeFileSync(filepath, cssRoot.toResult()); } } function cleanUp(cssRoot) { var handleBlock = function(block) { if (block.nodes && block.nodes.length === 0) { block.remove(); } }; cssRoot.walkRules(handleBlock); cssRoot.walkAtRules(handleBlock); cssRoot.raws.semicolon = true; } function hasEndMarker(block) { var result = false; block.walkComments(function(line) { if (line.text === userOptions.endTag) { result = true; } }); return result; } function applyUserOptions(newOptions) { var errorMessage ='', result = true; userOptions = merge(true, defaults); merge(userOptions, newOptions); if (userOptions.startTag === userOptions.endTag) { errorMessage += '\n\n'; errorMessage += 'ERROR :: PostCSS Plugin: Critical Split\n'; errorMessage += '.Critical CSS start and end tag must not be the same. \n'; errorMessage += 'Please adapt your options. \n\n'; errorMessage += '------ Current Options ----- \n\n'; errorMessage += JSON.stringify(userOptions, null, 2) + '\n\n'; errorMessage += '---------- End -------------\n'; console.error(errorMessage); result = false; } if (typeof userOptions.modules === 'string'){ userOptions.modules = [userOptions.modules]; } else if (userOptions.modules instanceof Array === false) { userOptions.modules = defaults.modules; } return result; } function createCriticalFilename(filename) { var position = filename.lastIndexOf('.css'), result = ''; result = filename.substring(0, position); result += userOptions.suffix; result += '.css'; return result; } function getAllCriticals(originalCss, criticalCss) { var currentLevel = null, blockMarkers = getModuleMarkers(userOptions.blockTag), moduleMarkers = getModuleMarkers(userOptions.startTag); stats.startTime = timestamp(); originalCss.walk(function(line) { var temp = null; stats.rules++; line.parent.raws.semicolon = true; stats.parentRequest++; if (line.type === 'comment' && (line.text === userOptions.endTag || line.text === '! ' + userOptions.endTag)) { criticalActive = false currentLevel = null; line.remove(); // remove tagging comment } else if (line.type === 'comment' && isBlockTag(line.text, blockMarkers)) { if (hasParentAtRule(line, 'keyframes')) { stats.criticals++; temp = appendFullBlock(criticalCss, line, 'keyframes'); removeMarkersInBlock(temp, blockMarkers, moduleMarkers); } else { appendFullBlock(criticalCss, line); line.remove(); // remove tagging comment } } else if (line.type === 'comment' && isStartTag(line.text, moduleMarkers)) { criticalActive = true; line.remove(); // remove tagging comment } else if (criticalActive === true && (line.type === 'atrule' && line.name === 'keyframes')) { //keyframes shouldn't be split stats.criticals++; temp = appendDeclaration(criticalCss, line); removeMarkersInBlock(temp, blockMarkers, moduleMarkers); if (hasEndMarker(line) === true) { criticalActive = false; } } else if (criticalActive === true && (line.type === 'decl' && hasParentAtRule(line, 'keyframes') === true)) { // ignore this rule... } else if (criticalActive === true && (line.type === 'atrule' && line.name === 'font-face')){ // this is a rather difficult one // @font-face is a 'naked atrule': it has no params at all // it is defined once every time you want to add a font // so we can't rely on 'searching for existing parent atrules' for new declarations as it might cross the context // so we manually add the atrule ourselves whenever we come across once stats.criticals++; appendEmptyRule(criticalCss, line); } else if (criticalActive === true && (line.type === 'decl' || line.type === 'comment')) { stats.criticals++; appendDeclaration(criticalCss, line); line.remove(); // remove line from originalCss as it is now alive in criticalCss } }); stats.endTime = timestamp(); originalCss.raws.semicolon = true; } function getModuleMarkers(startTag) { var modules = userOptions.modules, markers = null; if (userOptions.modules !== null) { markers = []; modules.forEach(function(currentModule){ stats.loops++; markers.push(startTag + userOptions.separator + currentModule); }); } return markers; } function isMarkedTag(currentText, marker, markers) { var result = false, typedMarker = marker, typedMarkers = markers; if (currentText.indexOf('! ') === 0) { typedMarker = '! ' + marker; if (typedMarkers !== null) { typedMarkers = markers.map(function(thisMarker) { return '! ' + thisMarker; }); } } if (currentText === typedMarker) { result = true; } else if (typedMarkers !== null && typedMarkers.indexOf(currentText) != -1) { result = true; } return result; } function isBlockTag(currentText, moduleMarkers) { return isMarkedTag(currentText, userOptions.blockTag, moduleMarkers); } function isStartTag(currentText, moduleMarkers) { return isMarkedTag(currentText, userOptions.startTag, moduleMarkers); } function getBlockFromTriggerTag(line, parentAtRule) { var result = null; if (typeof parentAtRule === 'undefined') { stats.parentRequest++; if (line.parent.type !== 'root') { result = line.parent; stats.parentRequest++; } } else { result = getParentAtRule(line, parentAtRule, true); } return result; } function appendFullBlock(criticalCss, line, parentAtRule) { var currentLevel = null, parents = null, block = getBlockFromTriggerTag(line, parentAtRule), temp = null; if (block !== null) { parents = getParents(line); if (block.type === 'atrule' && block.name === 'font-face') { appendEmptyRule(criticalCss, block); } currentLevel = prepareSelectors(criticalCss, parents); if (currentLevel.type === 'rule' || currentLevel.type === 'atrule') { if (block.name === 'keyframes') { if (areTheSame(block, currentLevel)) { temp = currentLevel; currentLevel = currentLevel.parent; temp.remove(); } currentLevel.append(clone(block)); } else { block.walk(function(currentLine) { var level = null; // we don't want to add the blockTag comment back; skip that // we don't want to loop over content that is inside a keyframes rule, it has been added already if (!(currentLine.type === 'comment' && line.text === currentLine.text)){ currentLevel.append(currentLine.clone()); stats.appends++; currentLine.remove(); currentLevel.raws.semicolon = true; } }); } } } return currentLevel; } function appendDeclaration(criticalCss, line) { var parents = getParents(line), currentLevel = prepareSelectors(criticalCss, parents), rule = clone(line); currentLevel.append(rule); stats.appends++; currentLevel.raws.semicolon = true; return rule; } function removeCommentIfMarker(blockMarkers, moduleMarkers, line) { if (line !== null) { if(line.type === 'comment' && (line.text === userOptions.endTag || isBlockTag(line.text, blockMarkers)) || isStartTag(line.text, moduleMarkers)) { line.remove(); } } } function removeMarkersInBlock(line, blockMarkers, moduleMarkers) { if (line !== null && typeof line.walkComments === 'function') { line.walkComments(removeCommentIfMarker.bind(null, blockMarkers, moduleMarkers)); } else { removeCommentIfMarker(blockMarkers, moduleMarkers, line); } } function appendEmptyRule(criticalCss, line) { var rule = clone(line, true); appendDeclaration(criticalCss, rule); return rule; } function prepareSelectors(criticalCss, selectorLevels) { var currentLevel = null; currentLevel = findSelector(criticalCss, selectorLevels); if (currentLevel === null) { currentLevel = createSelectorLevels(criticalCss, selectorLevels); currentLevel.raws.semicolon = true; } return currentLevel; } function createSelectorLevels(criticalCss, selectorLevels) { var i = null, currentLevel = null, temp = null; currentLevel = criticalCss; for (i = 0; i < selectorLevels.length; i++) { stats.loops++; temp = selectorLevels[i]; if (typeof currentLevel.last !== 'undefined' && areTheSame(temp, currentLevel.last)) { currentLevel = currentLevel.last; } else { if (currentLevel.name !== 'keyframes') { currentLevel.append(temp); currentLevel = temp; currentLevel.raws.semicolon = true; } temp = null; } } return currentLevel; } function findSelector(criticalCss, selectorLevels) { var result = null, currentLevel = null, temp = null, i = null; currentLevel = criticalCss; for (i = 0; i < selectorLevels.length; i++) { stats.loops++; temp = selectorLevels[i]; currentLevel = currentLevel.last; if (typeof currentLevel === 'undefined' || areTheSame(temp, currentLevel) === false) { currentLevel = null; break; } } result = currentLevel; return result; } function areTheSame(a, b) { var tempA = null, tempB = null, result = false; if (a.type === b.type) { stats.compares++; tempA = clone(a, true); tempB = clone(b, true); if (tempA.toString() === tempB.toString()) { result = true; } } return result; } function getParentAtRule(line, name) { var result = null, currentParent = line.parent; while (result === null && typeof currentParent !== 'undefined' && currentParent !== null) { if (currentParent.type === 'atrule' && currentParent.name === name) { result = currentParent; } else { stats.parentRequest++; currentParent = currentParent.parent; } } return result; } function hasParentAtRule(line, name) { var result = true, parent = getParentAtRule(line, name); if (parent === null) { result = false; } return result; } function getParents(line) { var parents = [], currentParent = null, temp = null; stats.getParents++; currentParent = line.parent; stats.parentRequest++; while (typeof currentParent !== 'undefined' && currentParent.type !== 'root') { temp = clone(currentParent, true); parents.push(temp); temp = null; currentParent = currentParent.parent; stats.parentRequest++; } parents = parents.reverse(); return parents; } function clone(originalRule, makeEmpty) { var newRule = null, temp = null; if (makeEmpty === true) { temp = originalRule.nodes; originalRule.nodes = []; newRule = originalRule.clone(); originalRule.nodes = temp; stats.emtpyClones++; } else { newRule = originalRule.clone(); stats.clones++; } return newRule; } module.exports = postcss.plugin('postcss-critical-split', CriticalSplit); module.exports.output_types = output_types;