UNPKG

santi

Version:

Isomorphic framework for base on create-react-app and jsdom

326 lines (301 loc) 9.66 kB
// From: https://github.com/uncss/uncss/blob/0.17.3/src/lib.js const postcss = require('postcss'), postcssSelectorParser = require('postcss-selector-parser'), _ = require('lodash') /** * Filter unused selectors. * @param {Object} window A jsdom window * @param {Array} sels List of selectors to be filtered * @return {Array} */ function findAll(window, sels) { const document = window.document // Unwrap noscript elements. const elements = document.getElementsByTagName('noscript') Array.prototype.forEach.call(elements, (ns) => { const wrapper = document.createElement('div') wrapper.innerHTML = ns.textContent // Insert each child of the <noscript> as its sibling Array.prototype.forEach.call(wrapper.children, (child) => { ns.parentNode.insertBefore(child, ns) }) }) // Do the filtering. return sels.filter((selector) => { try { return document.querySelector(selector) // eslint-disable-next-line no-unused-vars } catch (e) { // We ignore the error because there are a bunch of selectors which are out of our control. // In that case, we consider them as unused. return true } }) } /* Some styles are applied only with user interaction, and therefore its * selectors cannot be used with querySelectorAll. * http://www.w3.org/TR/2001/CR-css3-selectors-20011113/ */ const dePseudify = (() => { const ignoredPseudos = [ /* link */ ':link', ':visited', /* user action */ ':hover', ':active', ':focus', ':focus-within', /* UI element states */ ':enabled', ':disabled', ':checked', ':indeterminate', /* form validation */ ':required', ':invalid', ':valid', /* pseudo elements */ '::first-line', '::first-letter', '::selection', '::before', '::after', /* pseudo classes */ ':target', /* CSS2 pseudo elements */ ':before', ':after', /* Vendor-specific pseudo-elements: * https://developer.mozilla.org/ja/docs/Glossary/Vendor_Prefix */ '::?-(?:moz|ms|webkit|o)-[a-z0-9-]+', ], // Actual regex is of the format: /^(:hover|:focus|...)$/i pseudosRegex = new RegExp(`^(${ignoredPseudos.join('|')})$`, 'i') const transform = (selectors) => { selectors.walkPseudos((selector) => { if (pseudosRegex.test(selector.value)) { selector.remove() } }) } const processor = postcssSelectorParser(transform) return (selector) => processor.processSync(selector) })() /** * Private function used in filterUnusedRules. * @param {Array} selectors CSS selectors created by the CSS parser * @param {Array} ignore List of selectors to be ignored * @param {Array} usedSelectors List of Selectors found in the jsdom pages * @return {Array} The selectors matched in the DOMs */ function filterUnusedSelectors(selectors, ignore, usedSelectors) { /* There are some selectors not supported for matching, like * :before, :after * They should be removed only if the parent is not found. * Example: '.clearfix:before' should be removed only if there * is no '.clearfix' */ return selectors.filter((selector) => { selector = dePseudify(selector) /* TODO: process @-rules */ if (selector[0] === '@') { return true } for (let i = 0, len = ignore.length; i < len; ++i) { if (_.isRegExp(ignore[i]) && ignore[i].test(selector)) { return true } if (/:\w+/.test(ignore[i])) { const ignored = dePseudify(ignore[i]) if (ignored === selector) { return true } } if (ignore[i] === selector) { return true } } return usedSelectors.indexOf(selector) !== -1 }) } /** * Filter @keyframes that are not used * @param {Object} css The postcss.Root node * @param {Array} animations * @param {Array} unusedRules * @return {Array} */ function filterKeyframes(css, unusedRules) { const usedAnimations = [] css.walkDecls((decl) => { if (_.endsWith(decl.prop, 'animation-name')) { /* Multiple animations, separated by comma */ usedAnimations.push(...postcss.list.comma(decl.value)) } else if (_.endsWith(decl.prop, 'animation')) { /* Support multiple animations */ postcss.list.comma(decl.value).forEach((anim) => { /* If declared as animation, name can be anywhere in the string; so we include all the properties */ usedAnimations.push(...postcss.list.space(anim)) }) } }) const usedAnimationsSet = new Set(usedAnimations) css.walkAtRules(/keyframes$/, (atRule) => { if (!usedAnimationsSet.has(atRule.params)) { unusedRules.push(atRule) atRule.remove() } }) } /** * Filter rules with no selectors remaining * @param {Object} css The postcss.Root node * @return {Array} */ function filterEmptyAtRules(css) { /* Filter media queries with no remaining rules */ css.walkAtRules((atRule) => { if (atRule.name === 'media' && atRule.nodes.length === 0) { atRule.remove() } }) } /** * Find which selectors are used in {pages} * @param {Array} page List of jsdom pages * @param {Object} css The postcss.Root node * @return {Promise} */ function getUsedSelectors(page, css) { let usedSelectors = [] css.walkRules((rule) => { rule.selectors.filter((s) => /^@/.test(s)).forEach((s) => console.log(s)) usedSelectors = _.concat(usedSelectors, rule.selectors.map(dePseudify)) }) return findAll(page.window, usedSelectors) } /** * Get all the selectors mentioned in {css} * @param {Object} css The postcss.Root node * @return {Array} */ function getAllSelectors(css) { let selectors = [] css.walkRules((rule) => { selectors = _.concat(selectors, rule.selector) }) return selectors } /** * Remove css rules not used in the dom * @param {Array} pages List of jsdom pages * @param {Object} css The postcss.Root node * @param {Array} ignore List of selectors to be ignored * @param {Array} usedSelectors List of selectors that are found in {pages} * @return {Object} A css_parse-compatible stylesheet */ function filterUnusedRules(css, ignore, usedSelectors) { let ignoreNextRule = false, ignoreNextRulesStart = false, unusedRuleSelectors, usedRuleSelectors const unusedRules = [] /* Rule format: * { selectors: [ '...', '...' ], * declarations: [ { property: '...', value: '...' } ] * },. * Two steps: filter the unused selectors for each rule, * filter the rules with no selectors */ ignoreNextRule = false css.walk((rule) => { if (rule.type === 'comment') { if (/^!?\s?uncss:ignore start\s?$/.test(rule.text)) { // ignore next rules while using comment `/* uncss:ignore start */` ignoreNextRulesStart = true } else if (/^!?\s?uncss:ignore end\s?$/.test(rule.text)) { // until `/* uncss:ignore end */` was found ignoreNextRulesStart = false } else if (/^!?\s?uncss:ignore\s?$/.test(rule.text)) { // ignore next rule while using comment `/* uncss:ignore */` ignoreNextRule = true } } else if (rule.type === 'rule') { if ( rule.parent.type === 'atrule' && _.endsWith(rule.parent.name, 'keyframes') ) { // Don't remove animation keyframes that have selector names of '30%' or 'to' return } if (ignoreNextRulesStart) { ignore = ignore.concat(rule.selectors) } else if (ignoreNextRule) { ignoreNextRule = false ignore = ignore.concat(rule.selectors) } usedRuleSelectors = filterUnusedSelectors( rule.selectors, ignore, usedSelectors ) unusedRuleSelectors = rule.selectors.filter( (selector) => usedRuleSelectors.indexOf(selector) < 0 ) if (unusedRuleSelectors && unusedRuleSelectors.length) { unusedRules.push({ type: 'rule', selectors: unusedRuleSelectors, position: rule.source, }) } if (usedRuleSelectors.length === 0) { rule.remove() } else { rule.selectors = usedRuleSelectors } } }) /* Filter the @media rules with no rules */ filterEmptyAtRules(css) /* Filter unused @keyframes */ filterKeyframes(css, unusedRules) return css } /** * Main exposed function * @param {Array} pages List of jsdom pages * @param {Object} css The postcss.Root node * @param {Array} ignore List of selectors to be ignored * @return {Promise} */ module.exports = async function uncss(page, cssText, ignore = []) { const pages = [page] const css = postcss.parse(cssText) css.walkAtRules('font-face', (rule) => { rule.remove() }) const nestedUsedSelectors = await Promise.all( pages.map((page) => getUsedSelectors(page, css)) ) const usedSelectors = _.flatten(nestedUsedSelectors) const filteredCss = filterUnusedRules(css, ignore, usedSelectors) // console.log(usedSelectors) // const allSelectors = getAllSelectors(css) let newCssStr = '' postcss.stringify(filteredCss, (result) => { newCssStr += result }) return newCssStr // return [ // filteredCss, // { // /* Get the selectors for the report */ // all: allSelectors, // unused: _.difference(allSelectors, usedSelectors), // used: usedSelectors, // }, // ] }