postcss-classes-to-mixins
Version:
PostCSS plugin to intelligently convert CSS classes to SASS, Less and Stylus mixins
133 lines (117 loc) • 4.98 kB
JavaScript
const util = require('util')
const fs = require('fs')
const writeFile = util.promisify(fs.writeFile)
module.exports = (opts = {}) => {
return {
postcssPlugin: 'postcss-classes-to-mixins',
Once (root, { result }) {
const cssArray = postcssToArray(root)
const targets = ['scss', 'less', 'styl']
return Promise.all(targets
.filter((ext) => typeof opts[ext] === 'string')
.map((ext) => writeFile(opts[ext], toString(nestRules({
rules: cssArray,
target: ext,
mixinsOnly: opts.mixinsOnly
}))))
)
}
}
}
// Allow PostCSS to distinguish between require('plugin') and require('plugin')(opts) end-user calls
module.exports.postcss = true
function postcssToArray (root) {
const rules = []
root.nodes?.forEach((node) => {
if (node.type === 'atrule' && node.name === 'charset') {
rules.push([`@${node.name} ${node.params}`, ''])
} else if (node.type === 'atrule' && node.name === 'font-face') {
const style = node.nodes.reduce((acc, { prop, value }) => acc.concat([[prop, value]]), [])
rules.push([`@${node.name}`, style])
} else if (node.type === 'atrule') {
rules.push([`@${node.name} ${node.params}`, postcssToArray(node)])
} else if (node.type === 'rule') {
const style = node.nodes.reduce((acc, { prop, value, important }) => {
return acc.concat(prop ? [[prop, `${value}${important ? '!important' : ''}`]] : [])
}, [])
node.selector.split(',').forEach((selector) => {
rules.push([selector.trim(), style])
})
}
})
return rules
}
function nestRules ({ rules, target, nested = [], mediaQuery = false, mixinsOnly = false }) {
rules.forEach(([selector, rule]) => {
const isAtRule = selector.match(/@(media|supports)/)
const className = selector.match(/\.-?[_a-zA-Z]+[_a-zA-Z0-9-]*/)
let ruleset = rule
if (isAtRule) nestRules({ rules: rule, mediaQuery: selector, target, nested, mixinsOnly })
else if (className) {
let mixin = className[0].slice(1)
switch (target) {
case 'styl': mixin = `${mixin}()`; break
case 'less': mixin = `.${mixin}()`; break
case 'scss': mixin = `@mixin ${mixin}`; break
}
const parent = nested.filter(([m]) => m === mixin)[0] || nested[nested.push([mixin, []]) - 1]
const prefix = selector.slice(0, className.index)
const suffix = selector.slice(className.index + className[0].length).replace(className[0], '&')
if (prefix) {
if (target !== 'scss') ruleset = [[`${prefix}&${suffix}`, rule]]
else ruleset = [[`@at-root ${prefix}#{&}${suffix.replace('&', '#{&}')}`, rule]]
} else if (suffix) {
ruleset = [[`&${suffix}`, rule]]
}
// Include keyframes
rule.forEach(([name, props]) => {
if (name.indexOf('animation') === 0) {
const keyName = `@keyframes ${props.match(/\b[\w-]+\b/)}`
const keyRule = rules.filter(([name]) => name === keyName)[0]
if (keyRule) ruleset.push(keyRule)
}
})
if (mediaQuery) ruleset = [[mediaQuery, ruleset]]
parent[1].push(...ruleset)
} else if (!mixinsOnly) {
if (mediaQuery) ruleset = [[mediaQuery, ruleset]]
nested.push([selector, ruleset])
}
})
return nested
}
function toString (rules, pad = '') {
return rules.map(([key, val]) => {
if (typeof key === 'string' && key.startsWith('@charset ')) return `${pad}${key};\n`
if (typeof val === 'string') return `${pad}${key}: ${val};\n`
return `${pad}${key} {\n${toString(val, pad + ' ')}${pad}}\n`
}).join('')
}
// If ever needed to use without postcss, this also works:
// const { JSDOM } = require('jsdom')
// const dom = new JSDOM(`<style>${fs.readFileSync('./lib/core-css.css')}</style>`)
// const css = dom.window.document.styleSheets[0]
// // Returns array of [[selector, props], ...] to support multiple equal selectors and consistent order
// function cssToArray (root) {
// const rules = []
//
// Array.from(root.cssRules).forEach((rule) => {
// const type = rule.constructor.name.replace(/^CSS|Rule$/g, '').toLowerCase()
//
// if (type === 'media') rules.push([`@media ${Array.from(rule.media).join(',')}`, cssToArray(rule)])
// else if (type === 'supports') rules.push([`@supports ${rule.conditionText}`, cssToArray(rule)])
// else if (type === 'keyframes') rules.push([`@keyframes ${rule.name}`, cssToArray(rule)])
// else if (type === 'style' || type === 'keyframe') {
// const selector = rule.selectorText || rule.keyText
// const style = Array.from(rule.style).map((prop) => {
// const priority = rule.style.getPropertyPriority(prop).replace(/./, '!$&')
// return [prop, `${rule.style.getPropertyValue(prop)}${priority}`]
// })
// selector.split(',').forEach((selector) => {
// rules.push([selector.trim(), style])
// })
// }
// })
//
// return rules
// }