Merge pull request #1162 from tailwindcss/allow-plugins-to-modify-config

Allow plugins to add their own config file to be resolved with the user's custom config
This commit is contained in:
Adam Wathan 2019-10-14 12:00:29 -04:00 • committed by GitHub
commit 7a7303b3a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 633 additions and 16 deletions

View file

@ -9,6 +9,7 @@
"extends": ["eslint-config-postcss", "prettier"],
"plugins": ["prettier"],
"rules": {
"no-unused-vars": [2, {"args": "all", "argsIgnorePattern": "^_"}],
"prettier/prettier": [
"error",
{

View file

@ -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
}
}
`)
})

View file

@ -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,
})
})

View file

@ -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])
}

View file

@ -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 => {

View file

@ -8,7 +8,7 @@ function updateSource(nodes, source) {
}
export default function(
config,
_config,
{ base: pluginBase, components: pluginComponents, utilities: pluginUtilities }
) {
return function(css) {

View file

@ -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),

View file

@ -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
)
}