UNPKG

@knagis/postcss-advanced-variables

Version:

Use Sass-like variables, conditionals, and iterators in CSS

690 lines (542 loc) 23.6 kB
'use strict'; function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var path = _interopDefault(require('path')); var postcss = require('postcss'); var postcss__default = _interopDefault(postcss); var resolve = _interopDefault(require('@csstools/sass-import-resolve')); // return the closest variable from a node function getClosestVariable(name, node, opts) { const variables = getVariables(node); let variable = variables[name]; if (requiresAncestorVariable(variable, node)) { variable = getClosestVariable(name, node.parent, opts); } if (requiresFnVariable(variable, opts)) { variable = getFnVariable(name, node, opts.variables); } return variable; } // return the variables object of a node const getVariables = node => Object(Object(node).variables); // return whether the variable should be replaced using an ancestor variable const requiresAncestorVariable = (variable, node) => undefined === variable && node && node.parent; // return whether variable should be replaced using a variables function const requiresFnVariable = (value, opts) => value === undefined && Object(opts).variables === Object(Object(opts).variables); // return whether variable should be replaced using a variables function const getFnVariable = (name, node, variables) => 'function' === typeof variables ? variables(name, node) : variables[name]; function manageUnresolved(node, opts, word, message) { if ('warn' === opts.unresolved) { node.warn(opts.result, message, { word }); } else if ('ignore' !== opts.unresolved) { throw node.error(message, { word }); } } // tooling function getReplacedString(string, node, opts) { const replacedString = string.replace(matchVariables, (match, before, name1, name2, name3) => { // conditionally return an (unescaped) match if (before === '\\') { return match.slice(1); } // the first matching variable name const name = name1 || name2 || name3; // the closest variable value const value = getClosestVariable(name, node.parent, opts); // if a variable has not been resolved if (undefined === value) { manageUnresolved(node, opts, name, `Could not resolve the variable "$${name}" within "${string}"`); return match; } // the stringified value const stringifiedValue = `${before}${stringify(value)}`; return stringifiedValue; }); return replacedString; } // match all $name, $(name), and #{$name} variables (and catch the character before it) const matchVariables = /(.?)(?:\$([A-z][\w-]*)|\$\(([A-z][\w-]*)\)|#\{\$([A-z][\w-]*)\})/g; // return a sass stringified variable const stringify = object => Array.isArray(object) ? `(${object.map(stringify).join(',')})` : Object(object) === object ? `(${Object.keys(object).map(key => `${key}:${stringify(object[key])}`).join(',')})` : String(object); // tooling function setVariable(node, name, value, opts) { // if the value is not a default with a value already defined if (!matchDefault.test(value) || getClosestVariable(name, node, opts) === undefined) { // the value without a default suffix const undefaultedValue = matchDefault.test(value) ? value.replace(matchDefault, '') : value; // ensure the node has a variables object node.variables = node.variables || {}; // set the variable node.variables[name] = undefaultedValue; } } // match anything ending with a valid !default const matchDefault = /\s+!default$/; // tooling function transformDecl(decl, opts) { // update the declaration value with its variables replaced by their corresponding values decl.value = getReplacedString(decl.value, decl, opts); // if the declaration is a variable declaration if (isVariableDeclaration(decl)) { // set the variable on the parent of the declaration setVariable(decl.parent, decl.prop.slice(1), decl.value, opts); // remove the declaration decl.remove(); } } // return whether the declaration property is a variable declaration const isVariableDeclaration = decl => matchVariable.test(decl.prop); // match a variable ($any-name) const matchVariable = /^\$[\w-]+$/; // tooling function transformAtrule(rule, opts) { // update the at-rule params with its variables replaced by their corresponding values rule.params = getReplacedString(rule.params, rule, opts); } function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); } function _toArray(arr) { return _arrayWithHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableRest(); } function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); } function _iterableToArrayLimit(arr, i) { var _i = arr && (typeof Symbol !== "undefined" && arr[Symbol.iterator] || arr["@@iterator"]); if (_i == null) return; var _arr = []; var _n = true; var _d = false; var _s, _e; try { for (_i = _i.call(arr); !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function getValueAsObject(value) { const hasWrappingParens = matchWrappingParens.test(value); const unwrappedValue = String(hasWrappingParens ? value.replace(matchWrappingParens, '$1') : value).replace(matchTrailingComma, ''); const separatedValue = postcss.list.comma(unwrappedValue); const firstValue = separatedValue[0]; if (firstValue === value) { return value; } else { const objectValue = {}; const arrayValue = []; separatedValue.forEach((subvalue, index) => { const _ref = subvalue.match(matchDeclaration) || [], _ref2 = _slicedToArray(_ref, 3), match = _ref2[0], key = _ref2[1], keyvalue = _ref2[2]; if (match) { objectValue[key] = getValueAsObject(keyvalue); } else { arrayValue[index] = getValueAsObject(subvalue); } }); const transformedValue = Object.keys(objectValue).length > 0 ? Object.assign(objectValue, arrayValue) : arrayValue; return transformedValue; } } // match wrapping parentheses ((), (anything), (anything (anything))) const matchWrappingParens = /^\(([\W\w]*)\)$/g; // match a property name (any-possible_name) const matchDeclaration = /^([\w-]+)\s*:\s*([\W\w]+)\s*$/; // match a trailing comma const matchTrailingComma = /\s*,\s*$/; var waterfall = ((items, asyncFunction) => items.reduce((lastPromise, item) => lastPromise.then(() => asyncFunction(item)), Promise.resolve())); // tooling function transformEachAtrule(rule, opts) { // if @each is supported if (opts.transform.indexOf('@each') !== -1) { // @each options const _getEachOpts = getEachOpts(rule, opts), varname = _getEachOpts.varname, incname = _getEachOpts.incname, list = _getEachOpts.list; const replacements = []; const ruleClones = []; Object.keys(list).forEach(key => { // set the current variable setVariable(rule, varname, list[key], opts); // conditionally set the incremenator variable if (incname) { setVariable(rule, incname, key, opts); } // clone the @each at-rule const clone = rule.clone({ parent: rule.parent, variables: Object.assign({}, rule.variables) }); ruleClones.push(clone); }); return waterfall(ruleClones, clone => transformNode(clone, opts).then(() => { replacements.push(...clone.nodes); })).then(() => { // replace the @each at-rule with the replacements rule.parent.insertBefore(rule, replacements); rule.remove(); }); } } // return the @each statement options (@each NAME in LIST, @each NAME ITERATOR in LIST) const getEachOpts = (node, opts) => { const params = node.params.split(matchInOperator); const args = (params[0] || '').trim().split(' '); const varname = args[0].trim().slice(1); const incname = args.length > 1 && args[1].trim().slice(1); const rawlist = getValueAsObject(getReplacedString(params.slice(1).join(matchInOperator), node, opts)); const list = 'string' === typeof rawlist ? [rawlist] : rawlist; return { varname, incname, list }; }; // match the opertor separating the name and iterator from the list const matchInOperator = ' in '; // tooling function transformIfAtrule(rule, opts) { // @if options const isTruthy = isIfTruthy(rule, opts); const next = rule.next(); const transformAndInsertBeforeParent = node => transformNode(node, opts).then(() => node.parent.insertBefore(node, node.nodes)); return ifPromise(opts.transform.indexOf('@if') !== -1, () => ifPromise(isTruthy, () => transformAndInsertBeforeParent(rule)).then(() => { rule.remove(); })).then(() => ifPromise(opts.transform.indexOf('@else') !== -1 && isElseRule(next), () => ifPromise(!isTruthy, () => transformAndInsertBeforeParent(next)).then(() => { next.remove(); }))); } const ifPromise = (condition, trueFunction) => Promise.resolve(condition && trueFunction()); // return whether the @if at-rule is truthy const isIfTruthy = (node, opts) => { // @if statement options (@if EXPRESSION, @if LEFT OPERATOR RIGHT) const params = postcss.list.space(node.params); const left = getInterprettedString(getReplacedString(params[0] || '', node, opts)); const operator = params[1]; const right = getInterprettedString(getReplacedString(params[2] || '', node, opts)); // evaluate the expression const isTruthy = !operator && left || operator === '==' && left === right || operator === '!=' && left !== right || operator === '<' && left < right || operator === '<=' && left <= right || operator === '>' && left > right || operator === '>=' && left >= right; return isTruthy; }; // return the value as a boolean, number, or string const getInterprettedString = value => 'true' === value ? true : 'false' === value ? false : isNaN(value) ? value : Number(value); // return whether the node is an else at-rule const isElseRule = node => Object(node) === node && 'atrule' === node.type && 'else' === node.name; function transformImportAtrule(rule, opts) { // if @import is supported if (opts.transform.indexOf('@import') !== -1) { // @import options const _getImportOpts = getImportOpts(rule, opts), id = _getImportOpts.id, media = _getImportOpts.media, cwf = _getImportOpts.cwf, cwd = _getImportOpts.cwd; // PostCSS options const options = opts.result.opts; const parser = options.parser || options.syntax && options.syntax.parse || null; if (opts.importFilter instanceof Function && opts.importFilter(id, media) || opts.importFilter instanceof RegExp && opts.importFilter.test(id)) { const cwds = [cwd].concat(opts.importPaths); // promise the resolved file and its contents using the file resolver const importPromise = cwds.reduce((promise, thiscwd) => promise.catch(() => opts.importResolve(id, thiscwd, opts)), Promise.reject()); return importPromise.then( // promise the processed file ({ file, contents }) => processor.process(contents, { from: file, parser: parser }).then(({ root }) => { // push a dependency message opts.result.messages.push({ type: 'dependency', file: file, parent: cwf }); // imported nodes const nodes = root.nodes.slice(0); // if media params were detected if (media) { // create a new media rule const mediaRule = postcss__default.atRule({ name: 'media', params: media, source: rule.source }); // append with the imported nodes mediaRule.append(nodes); // replace the @import at-rule with the @media at-rule rule.replaceWith(mediaRule); } else { // replace the @import at-rule with the imported nodes rule.replaceWith(nodes); } // transform all nodes from the import return transformNode({ nodes }, opts); }), () => { // otherwise, if the @import could not be found manageUnresolved(rule, opts, '@import', `Could not resolve the @import for "${id}"`); }); } } } const processor = postcss__default(); // return the @import statement options (@import ID, @import ID MEDIA) const getImportOpts = (node, opts) => { const _list$space = postcss.list.space(node.params), _list$space2 = _toArray(_list$space), rawid = _list$space2[0], medias = _list$space2.slice(1); const id = getReplacedString(trimWrappingURL(rawid), node, opts); const media = medias.join(' '); // current working file and directory const cwf = node.source && node.source.input && node.source.input.file || opts.result.from; const cwd = cwf ? path.dirname(cwf) : opts.importRoot; return { id, media, cwf, cwd }; }; // return a string with the wrapping url() and quotes trimmed const trimWrappingURL = string => trimWrappingQuotes(string.replace(/^url\(([\W\w]*)\)$/, '$1')); // return a string with the wrapping quotes trimmed const trimWrappingQuotes = string => string.replace(/^("|')([\W\w]*)\1$/, '$2'); function transformIncludeAtrule(rule, opts) { // if @include is supported if (opts.transform.indexOf('@include') !== -1) { // @include options const _getIncludeOpts = getIncludeOpts(rule), name = _getIncludeOpts.name, args = _getIncludeOpts.args; // the closest @mixin variable const mixin = getClosestVariable(`@mixin ${name}`, rule.parent, opts); // if the @mixin variable exists if (mixin) { // set @mixin variables on the @include at-rule mixin.params.forEach((param, index) => { const arg = index in args ? getReplacedString(args[index], rule, opts) : param.value; setVariable(rule, param.name, arg, opts); }); // clone the @mixin at-rule const clone = mixin.rule.clone({ original: rule, parent: rule.parent, variables: rule.variables }); // transform the clone children return transformNode(clone, opts).then(() => { // replace the @include at-rule with the clone children rule.parent.insertBefore(rule, clone.nodes); rule.remove(); }); } else { // otherwise, if the @mixin variable does not exist manageUnresolved(rule, opts, name, `Could not resolve the mixin for "${name}"`); } } } // return the @include statement options (@include NAME, @include NAME(ARGS)) const getIncludeOpts = node => { // @include name and args const _node$params$split = node.params.split(matchOpeningParen, 2), _node$params$split2 = _slicedToArray(_node$params$split, 2), name = _node$params$split2[0], sourceArgs = _node$params$split2[1]; const args = sourceArgs ? postcss.list.comma(sourceArgs.slice(0, -1)) : []; return { name, args }; }; // match an opening parenthesis const matchOpeningParen = '('; // tooling function transformForAtrule(rule, opts) { // if @for is supported if (opts.transform.indexOf('@for') !== -1) { // @for options const _getForOpts = getForOpts(rule, opts), varname = _getForOpts.varname, start = _getForOpts.start, end = _getForOpts.end, increment = _getForOpts.increment; const direction = start <= end ? 1 : -1; const replacements = []; const ruleClones = []; // for each iteration for (let incrementor = start; incrementor * direction <= end * direction; incrementor += increment * direction) { // set the current variable setVariable(rule, varname, incrementor, opts); // clone the @for at-rule const clone = rule.clone({ parent: rule.parent, variables: Object.assign({}, rule.variables) }); ruleClones.push(clone); } return waterfall(ruleClones, clone => transformNode(clone, opts).then(() => { replacements.push(...clone.nodes); })).then(() => { // replace the @for at-rule with the replacements rule.parent.insertBefore(rule, replacements); rule.remove(); }); } } // return the @for statement options (@for NAME from START through END, @for NAME from START through END by INCREMENT) const getForOpts = (node, opts) => { const params = postcss.list.space(node.params); const varname = params[0].trim().slice(1); const start = Number(getReplacedString(params[2], node, opts)); const end = Number(getReplacedString(params[4], node, opts)); const increment = 6 in params && Number(getReplacedString(params[6], node, opts)) || 1; return { varname, start, end, increment }; }; function transformMixinAtrule(rule, opts) { // if @mixin is supported if (opts.transform.indexOf('@mixin') !== -1) { // @mixin options const _getMixinOpts = getMixinOpts(rule, opts), name = _getMixinOpts.name, params = _getMixinOpts.params; // set the mixin as a variable on the parent of the @mixin at-rule setVariable(rule.parent, `@mixin ${name}`, { params, rule }, opts); // remove the @mixin at-rule rule.remove(); } } // return the @mixin statement options (@mixin NAME, @mixin NAME(PARAMS)) const getMixinOpts = (node, opts) => { // @mixin name and default params ([{ name, value }, ...]) const _node$params$split = node.params.split(matchOpeningParen$1, 2), _node$params$split2 = _slicedToArray(_node$params$split, 2), name = _node$params$split2[0], sourceParams = _node$params$split2[1]; const params = sourceParams && sourceParams.slice(0, -1).trim() ? postcss.list.comma(sourceParams.slice(0, -1).trim()).map(param => { const parts = postcss.list.split(param, ':'); const paramName = parts[0].slice(1); const paramValue = parts.length > 1 ? getReplacedString(parts.slice(1).join(':'), node, opts) : undefined; return { name: paramName, value: paramValue }; }) : []; return { name, params }; }; // match an opening parenthesis const matchOpeningParen$1 = '('; // tooling function transformRule(rule, opts) { // update the rule selector with its variables replaced by their corresponding values rule.selector = getReplacedString(rule.selector, rule, opts); } // tooling function transformContentAtrule(rule, opts) { // if @content is supported if (opts.transform.indexOf('@content') !== -1) { // the closest @mixin at-rule const mixin = getClosestMixin(rule); // if the @mixin at-rule exists if (mixin) { // clone the @mixin at-rule const clone = mixin.original.clone({ parent: rule.parent, variables: rule.variables }); // transform the clone children return transformNode(clone, opts).then(() => { // replace the @content at-rule with the clone children rule.parent.insertBefore(rule, clone.nodes); rule.remove(); }); } else { // otherwise, if the @mixin at-rule does not exist manageUnresolved(rule, opts, '@content', 'Could not resolve the mixin for @content'); } } } // return the closest @mixin at-rule const getClosestMixin = node => 'atrule' === node.type && 'mixin' === node.name ? node : node.parent && getClosestMixin(node.parent); // tooling function transformNode(node, opts) { return waterfall(getNodesArray(node), child => transformRuleOrDecl(child, opts).then(() => { // conditionally walk the children of the attached child if (child.parent) { return transformNode(child, opts); } })); } function transformRuleOrDecl(child, opts) { return Promise.resolve().then(() => { const type = child.type; if ('atrule' === type) { const name = child.name.toLowerCase(); if ('content' === name) { // transform @content at-rules return transformContentAtrule(child, opts); } else if ('each' === name) { // transform @each at-rules return transformEachAtrule(child, opts); } else if ('if' === name) { // transform @if at-rules return transformIfAtrule(child, opts); } else if ('import' === name) { return transformImportAtrule(child, opts); } else if ('include' === name) { // transform @include at-rules return transformIncludeAtrule(child, opts); } else if ('for' === name) { // transform @for at-rules return transformForAtrule(child, opts); } else if ('mixin' === name) { // transform mixin at-rules return transformMixinAtrule(child, opts); } else { // transform all other at-rules return transformAtrule(child, opts); } } else if ('decl' === type) { // transform declarations return transformDecl(child, opts); } else if ('rule' === type) { // transform rule return transformRule(child, opts); } }); } // return the children of a node as an array const getNodesArray = node => Array.from(Object(node).nodes || []); // tooling const matchProtocol = /^(?:[A-z]+:)?\/\//; // plugin const plugin = opts => ({ postcssPlugin: "postcss-advanced-variables", Root(root, { result }) { // process options const transformOpt = ['@content', '@each', '@else', '@if', '@include', '@import', '@for', '@mixin'].filter(feature => !(String(Object(opts).disable || '').split(/\s*,\s*|\s+,?\s*|\s,?\s+/).indexOf(feature) !== -1)); const unresolvedOpt = String(Object(opts).unresolved || 'throw').toLowerCase(); const variablesOpt = Object(opts).variables; const importCache = Object(Object(opts).importCache); const importFilter = Object(opts).importFilter || (id => { return !matchProtocol.test(id); }); const importPaths = [].concat(Object(opts).importPaths || []); const importResolve = Object(opts).importResolve || ((id, cwd) => resolve(id, { cwd, readFile: true, cache: importCache })); const importRoot = Object(opts).importRoot || process.cwd(); return transformNode(root, { result, importCache, importFilter, importPaths, importResolve, importRoot, transform: transformOpt, unresolved: unresolvedOpt, variables: variablesOpt }); } }); plugin.postcss = true; module.exports = plugin;