diff --git a/.eslintrc b/.eslintrc index 238612fbd..10b89fb05 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,7 @@ "extends": ["eslint-config-postcss", "prettier"], "plugins": ["prettier"], "rules": { + "no-unused-vars": [2, {"args": "all", "argsIgnorePattern": "^_"}], "prettier/prettier": [ "error", { diff --git a/__tests__/processPlugins.test.js b/__tests__/processPlugins.test.js index 58e8b85e0..f70c12e6f 100644 --- a/__tests__/processPlugins.test.js +++ b/__tests__/processPlugins.test.js @@ -47,7 +47,7 @@ test('plugins can create utilities with object syntax', () => { object-fit: cover } } - `) + `) }) test('plugins can create utilities with arrays of objects', () => { @@ -1206,3 +1206,84 @@ test('prefix will prefix all classes in a selector', () => { } `) }) + +test('plugins can be provided as an object with a handler function', () => { + const { components, utilities } = processPlugins( + [ + { + handler({ addUtilities }) { + addUtilities({ + '.object-fill': { + 'object-fit': 'fill', + }, + '.object-contain': { + 'object-fit': 'contain', + }, + '.object-cover': { + 'object-fit': 'cover', + }, + }) + }, + }, + ], + makeConfig() + ) + + expect(components.length).toBe(0) + expect(css(utilities)).toMatchCss(` + @variants { + .object-fill { + object-fit: fill + } + .object-contain { + object-fit: contain + } + .object-cover { + object-fit: cover + } + } + `) +}) + +test('plugins can provide a config but no handler', () => { + const { components, utilities } = processPlugins( + [ + { + config: { + prefix: 'tw-', + }, + }, + { + handler({ addUtilities }) { + addUtilities({ + '.object-fill': { + 'object-fit': 'fill', + }, + '.object-contain': { + 'object-fit': 'contain', + }, + '.object-cover': { + 'object-fit': 'cover', + }, + }) + }, + }, + ], + makeConfig() + ) + + expect(components.length).toBe(0) + expect(css(utilities)).toMatchCss(` + @variants { + .object-fill { + object-fit: fill + } + .object-contain { + object-fit: contain + } + .object-cover { + object-fit: cover + } + } + `) +}) diff --git a/__tests__/resolveConfig.test.js b/__tests__/resolveConfig.test.js index b63a4831c..27d267345 100644 --- a/__tests__/resolveConfig.test.js +++ b/__tests__/resolveConfig.test.js @@ -1234,3 +1234,480 @@ test('custom properties are multiplied by -1 for negative values', () => { variants: {}, }) }) + +test('more than two config objects can be resolved', () => { + const firstConfig = { + theme: { + extend: { + fontFamily: () => ({ + code: ['Menlo', 'monospace'], + }), + colors: { + red: 'red', + }, + backgroundColor: { + customBackgroundOne: '#bada55', + }, + textDecorationColor: { + orange: 'orange', + }, + }, + }, + } + + const secondConfig = { + prefix: '-', + important: false, + separator: ':', + theme: { + extend: { + fontFamily: { + quote: ['Helvetica', 'serif'], + }, + colors: { + green: 'green', + }, + backgroundColor: { + customBackgroundTwo: '#facade', + }, + textDecorationColor: theme => theme('colors'), + }, + }, + } + + const thirdConfig = { + prefix: '-', + important: false, + separator: ':', + theme: { + extend: { + fontFamily: { + hero: ['Futura', 'sans-serif'], + }, + colors: { + pink: 'pink', + }, + backgroundColor: () => ({ + customBackgroundThree: '#c0ffee', + }), + textDecorationColor: { + lime: 'lime', + }, + }, + }, + } + + const defaultConfig = { + prefix: '-', + important: false, + separator: ':', + theme: { + fontFamily: { + body: ['Arial', 'sans-serif'], + display: ['Georgia', 'serif'], + }, + colors: { + blue: 'blue', + }, + backgroundColor: theme => theme('colors'), + }, + variants: { + backgroundColor: ['responsive', 'hover', 'focus'], + }, + } + + const result = resolveConfig([firstConfig, secondConfig, thirdConfig, defaultConfig]) + + expect(result).toEqual({ + prefix: '-', + important: false, + separator: ':', + theme: { + fontFamily: { + body: ['Arial', 'sans-serif'], + display: ['Georgia', 'serif'], + code: ['Menlo', 'monospace'], + quote: ['Helvetica', 'serif'], + hero: ['Futura', 'sans-serif'], + }, + colors: { + red: 'red', + green: 'green', + blue: 'blue', + pink: 'pink', + }, + backgroundColor: { + red: 'red', + green: 'green', + blue: 'blue', + pink: 'pink', + customBackgroundOne: '#bada55', + customBackgroundTwo: '#facade', + customBackgroundThree: '#c0ffee', + }, + textDecorationColor: { + red: 'red', + green: 'green', + blue: 'blue', + pink: 'pink', + orange: 'orange', + lime: 'lime', + }, + }, + variants: { + backgroundColor: ['responsive', 'hover', 'focus'], + }, + }) +}) + +test('plugin config modifications are applied', () => { + const userConfig = { + plugins: [ + { + config: { + prefix: 'tw-', + }, + }, + ], + } + + const defaultConfig = { + prefix: '', + important: false, + separator: ':', + theme: { + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + } + + const result = resolveConfig([userConfig, defaultConfig]) + + expect(result).toEqual({ + prefix: 'tw-', + important: false, + separator: ':', + theme: { + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + plugins: userConfig.plugins, + }) +}) + +test('user config takes precedence over plugin config modifications', () => { + const userConfig = { + prefix: 'user-', + plugins: [ + { + config: { + prefix: 'tw-', + }, + }, + ], + } + + const defaultConfig = { + prefix: '', + important: false, + separator: ':', + theme: { + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + } + + const result = resolveConfig([userConfig, defaultConfig]) + + expect(result).toEqual({ + prefix: 'user-', + important: false, + separator: ':', + theme: { + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + plugins: userConfig.plugins, + }) +}) + +test('plugin config can register plugins that also have config', () => { + const userConfig = { + plugins: [ + { + config: { + prefix: 'tw-', + plugins: [ + { + config: { + important: true, + }, + }, + { + config: { + separator: '__', + }, + }, + ], + }, + handler() {}, + }, + ], + } + + const defaultConfig = { + prefix: '', + important: false, + separator: ':', + theme: { + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + } + + const result = resolveConfig([userConfig, defaultConfig]) + + expect(result).toEqual({ + prefix: 'tw-', + important: true, + separator: '__', + theme: { + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + plugins: userConfig.plugins, + }) +}) + +test('plugin configs take precedence over plugin configs registered by that plugin', () => { + const userConfig = { + plugins: [ + { + config: { + prefix: 'outer-', + plugins: [ + { + config: { + prefix: 'inner-', + }, + }, + ], + }, + handler() {}, + }, + ], + } + + const defaultConfig = { + prefix: '', + important: false, + separator: ':', + theme: { + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + } + + const result = resolveConfig([userConfig, defaultConfig]) + + expect(result).toEqual({ + prefix: 'outer-', + important: false, + separator: ':', + theme: { + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + plugins: userConfig.plugins, + }) +}) + +test('plugin theme extensions are added even if user overrides top-level theme config', () => { + const userConfig = { + theme: { + width: { + '1px': '1px', + }, + }, + plugins: [ + { + config: { + theme: { + extend: { + width: { + '2px': '2px', + '3px': '3px', + }, + }, + }, + }, + handler() {}, + }, + ], + } + + const defaultConfig = { + prefix: '', + important: false, + separator: ':', + theme: { + width: { + sm: '1rem', + md: '2rem', + lg: '3rem', + }, + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + } + + const result = resolveConfig([userConfig, defaultConfig]) + + expect(result).toEqual({ + prefix: '', + important: false, + separator: ':', + theme: { + width: { + '1px': '1px', + '2px': '2px', + '3px': '3px', + }, + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + plugins: userConfig.plugins, + }) +}) + +test('user theme extensions take precedence over plugin theme extensions with the same key', () => { + const userConfig = { + theme: { + extend: { + width: { + xl: '6rem', + }, + }, + }, + plugins: [ + { + config: { + theme: { + extend: { + width: { + xl: '4rem', + }, + }, + }, + }, + handler() {}, + }, + ], + } + + const defaultConfig = { + prefix: '', + important: false, + separator: ':', + theme: { + width: { + sm: '1rem', + md: '2rem', + lg: '3rem', + }, + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + } + + const result = resolveConfig([userConfig, defaultConfig]) + + expect(result).toEqual({ + prefix: '', + important: false, + separator: ':', + theme: { + width: { + sm: '1rem', + md: '2rem', + lg: '3rem', + xl: '6rem', + }, + screens: { + mobile: '400px', + }, + }, + variants: { + appearance: ['responsive'], + borderCollapse: [], + borderColors: ['responsive', 'hover', 'focus'], + }, + plugins: userConfig.plugins, + }) +}) diff --git a/resolveConfig.js b/resolveConfig.js index 776aa3ba4..ecd5bf1fd 100644 --- a/resolveConfig.js +++ b/resolveConfig.js @@ -1,6 +1,6 @@ const resolveConfigObjects = require('./lib/util/resolveConfig').default const defaultConfig = require('./stubs/defaultConfig.stub.js') -module.exports = function resolveConfig(config) { - return resolveConfigObjects([config, defaultConfig]) +module.exports = function resolveConfig(...configs) { + return resolveConfigObjects([...configs, defaultConfig]) } diff --git a/src/index.js b/src/index.js index b86050c2e..9dd2ab582 100644 --- a/src/index.js +++ b/src/index.js @@ -55,10 +55,9 @@ const getConfigFunction = config => () => { }) } - return resolveConfig([ - _.isObject(config) ? _.get(config, 'config', config) : require(config), - defaultConfig, - ]) + const configObject = _.isObject(config) ? _.get(config, 'config', config) : require(config) + + return resolveConfig([configObject, defaultConfig]) } const plugin = postcss.plugin('tailwind', config => { diff --git a/src/lib/substituteTailwindAtRules.js b/src/lib/substituteTailwindAtRules.js index 3aa181d29..6ceb38aa1 100644 --- a/src/lib/substituteTailwindAtRules.js +++ b/src/lib/substituteTailwindAtRules.js @@ -8,7 +8,7 @@ function updateSource(nodes, source) { } export default function( - config, + _config, { base: pluginBase, components: pluginComponents, utilities: pluginUtilities } ) { return function(css) { diff --git a/src/util/processPlugins.js b/src/util/processPlugins.js index c59af2918..1dcd7f627 100644 --- a/src/util/processPlugins.js +++ b/src/util/processPlugins.js @@ -1,6 +1,7 @@ import _ from 'lodash' import postcss from 'postcss' import Node from 'postcss/lib/node' +import isFunction from 'lodash/isFunction' import escapeClassName from '../util/escapeClassName' import generateVariantFunction from '../util/generateVariantFunction' import parseObjectStyles from '../util/parseObjectStyles' @@ -28,7 +29,9 @@ export default function(plugins, config) { const getConfigValue = (path, defaultValue) => _.get(config, path, defaultValue) plugins.forEach(plugin => { - plugin({ + const handler = isFunction(plugin) ? plugin : _.get(plugin, 'handler', () => {}) + + handler({ postcss, config: getConfigValue, theme: (path, defaultValue) => getConfigValue(`theme.${path}`, defaultValue), diff --git a/src/util/resolveConfig.js b/src/util/resolveConfig.js index 41e41526d..4ad99126b 100644 --- a/src/util/resolveConfig.js +++ b/src/util/resolveConfig.js @@ -1,7 +1,10 @@ +import some from 'lodash/some' import mergeWith from 'lodash/mergeWith' import isFunction from 'lodash/isFunction' +import isUndefined from 'lodash/isUndefined' import defaults from 'lodash/defaults' import map from 'lodash/map' +import get from 'lodash/get' import toPath from 'lodash/toPath' import negateValue from './negateValue' @@ -23,18 +26,47 @@ function value(valueToResolve, ...args) { return isFunction(valueToResolve) ? valueToResolve(...args) : valueToResolve } +function mergeThemes(themes) { + const theme = (({ extend: _, ...t }) => t)( + themes.reduce((merged, t) => { + return defaults(merged, t) + }, {}) + ) + + return { + ...theme, + + // In order to resolve n config objects, we combine all of their `extend` properties + // into arrays instead of objects so they aren't overridden. + extend: themes.reduce((merged, { extend }) => { + return mergeWith(merged, extend, (mergedValue, extendValue) => { + if (isUndefined(mergedValue)) { + return [extendValue] + } + + if (Array.isArray(mergedValue)) { + return [extendValue, ...mergedValue] + } + + return [extendValue, mergedValue] + }) + }, {}), + } +} + function mergeExtensions({ extend, ...theme }) { return mergeWith(theme, extend, (themeValue, extensions) => { - if (!isFunction(themeValue) && !isFunction(extensions)) { + // The `extend` property is an array, so we need to check if it contains any functions + if (!isFunction(themeValue) && !some(extensions, isFunction)) { return { ...themeValue, - ...extensions, + ...Object.assign({}, ...extensions), } } return (resolveThemePath, utils) => ({ ...value(themeValue, resolveThemePath, utils), - ...value(extensions, resolveThemePath, utils), + ...Object.assign({}, ...extensions.map(e => value(e, resolveThemePath, utils))), }) }) } @@ -62,16 +94,40 @@ function resolveFunctionKeys(object) { }, {}) } +function extractPluginConfigs(configs) { + let allConfigs = [] + + configs.forEach(config => { + allConfigs = [...allConfigs, config] + + const plugins = get(config, 'plugins', []) + + if (plugins.length === 0) { + return + } + + plugins.forEach(plugin => { + allConfigs = [...allConfigs, ...extractPluginConfigs([get(plugin, 'config', {})])] + }) + }) + + return allConfigs +} + export default function resolveConfig(configs) { + const allConfigs = extractPluginConfigs(configs) + return defaults( { - theme: resolveFunctionKeys(mergeExtensions(defaults({}, ...map(configs, 'theme')))), + theme: resolveFunctionKeys( + mergeExtensions(mergeThemes(map(allConfigs, t => get(t, 'theme', {})))) + ), variants: (firstVariants => { return Array.isArray(firstVariants) ? firstVariants - : defaults({}, ...map(configs, 'variants')) - })(defaults({}, ...map(configs)).variants), + : defaults({}, ...map(allConfigs, 'variants')) + })(defaults({}, ...map(allConfigs)).variants), }, - ...configs + ...allConfigs ) }