diff --git a/__tests__/applyClassPrefix.test.js b/__tests__/prefixTree.test.js similarity index 81% rename from __tests__/applyClassPrefix.test.js rename to __tests__/prefixTree.test.js index 3c36fac31675..cd3029fb4378 100644 --- a/__tests__/applyClassPrefix.test.js +++ b/__tests__/prefixTree.test.js @@ -1,5 +1,5 @@ import postcss from 'postcss' -import applyClassPrefix from '../src/util/applyClassPrefix' +import prefixTree from '../src/util/prefixTree' test('it prefixes classes with the provided prefix', () => { const input = postcss.parse(` @@ -12,7 +12,7 @@ test('it prefixes classes with the provided prefix', () => { .tw-apple, .tw-pear { color: green; } ` - const result = applyClassPrefix(input, 'tw-').toResult() + const result = prefixTree(input, 'tw-').toResult() expect(result.css).toEqual(expected) expect(result.warnings().length).toBe(0) }) @@ -36,7 +36,7 @@ test('it handles a function as the prefix', () => { return '' } - const result = applyClassPrefix(input, prefixFunc).toResult() + const result = prefixTree(input, prefixFunc).toResult() expect(result.css).toEqual(expected) expect(result.warnings().length).toBe(0) }) diff --git a/__tests__/processPlugins.test.js b/__tests__/processPlugins.test.js new file mode 100644 index 000000000000..e053350ec0f4 --- /dev/null +++ b/__tests__/processPlugins.test.js @@ -0,0 +1,497 @@ +import _ from 'lodash' +import postcss from 'postcss' +import processPlugins from '../src/util/processPlugins' + +function css(nodes) { + return postcss.root({ nodes }).toString() +} + +test('plugins can create utilities', () => { + const [components, utilities] = processPlugins({ + plugins: [ + function({ rule, addUtilities }) { + addUtilities([ + rule('.object-fill', { + 'object-fit': 'fill', + }), + rule('.object-contain', { + 'object-fit': 'contain', + }), + rule('.object-cover', { + 'object-fit': 'cover', + }), + ]) + }, + ], + }) + + 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 create utilities with variants', () => { + const [components, utilities] = processPlugins({ + plugins: [ + function({ rule, addUtilities }) { + addUtilities( + [ + rule('.object-fill', { + 'object-fit': 'fill', + }), + rule('.object-contain', { + 'object-fit': 'contain', + }), + rule('.object-cover', { + 'object-fit': 'cover', + }), + ], + ['responsive', 'hover', 'group-hover', 'focus'] + ) + }, + ], + }) + + expect(components.length).toBe(0) + expect(css(utilities)).toMatchCss(` + @variants responsive, hover, group-hover, focus { + .object-fill { + object-fit: fill + } + .object-contain { + object-fit: contain + } + .object-cover { + object-fit: cover + } + } + `) +}) + +test('plugins can create components', () => { + const [components, utilities] = processPlugins({ + plugins: [ + function({ rule, addComponents }) { + addComponents([ + rule('.btn-blue', { + 'background-color': 'blue', + color: 'white', + padding: '.5rem 1rem', + 'border-radius': '.25rem', + }), + rule('.btn-blue:hover', { + 'background-color': 'darkblue', + }), + ]) + }, + ], + }) + + expect(utilities.length).toBe(0) + expect(css(components)).toMatchCss(` + .btn-blue { + background-color: blue; + color: white; + padding: .5rem 1rem; + border-radius: .25rem + } + .btn-blue:hover { + background-color: darkblue + } + `) +}) + +test('plugins can create components with media queries', () => { + const [components, utilities] = processPlugins({ + plugins: [ + function({ rule, atRule, addComponents }) { + addComponents([ + rule('.container', { + width: '100%', + }), + atRule('@media (min-width: 100px)', [ + rule('.container', { + 'max-width': '100px', + }), + ]), + atRule('@media (min-width: 200px)', [ + rule('.container', { + 'max-width': '200px', + }), + ]), + atRule('@media (min-width: 300px)', [ + rule('.container', { + 'max-width': '300px', + }), + ]), + ]) + }, + ], + }) + + expect(utilities.length).toBe(0) + expect(css(components)).toMatchCss(` + .container { + width: 100% + } + @media (min-width: 100px) { + .container { + max-width: 100px + } + } + @media (min-width: 200px) { + .container { + max-width: 200px + } + } + @media (min-width: 300px) { + .container { + max-width: 300px + } + } + `) +}) + +test('plugins can create rules with escaped selectors', () => { + const config = { + plugins: [ + function({ e, rule, addUtilities }) { + addUtilities( + [ + rule(`.${e('top-1/4')}`, { + top: '25%', + }), + ], + [] + ) + }, + ], + } + + const [components, utilities] = processPlugins(config) + + expect(components.length).toBe(0) + expect(css(utilities)).toMatchCss(` + @variants { + .top-1\\/4 { + top: 25% + } + } + `) +}) + +test('plugins can access the current config', () => { + const [components, utilities] = processPlugins({ + screens: { + sm: '576px', + md: '768px', + lg: '992px', + xl: '1200px', + }, + plugins: [ + function({ rule, atRule, addComponents, config }) { + const containerClasses = [ + rule('.container', { + width: '100%', + }), + ] + + _.forEach(config('screens'), breakpoint => { + const mediaQuery = atRule(`@media (min-width: ${breakpoint})`, [ + rule('.container', { 'max-width': breakpoint }), + ]) + containerClasses.push(mediaQuery) + }) + + addComponents(containerClasses) + }, + ], + }) + + expect(utilities.length).toBe(0) + expect(css(components)).toMatchCss(` + .container { + width: 100% + } + @media (min-width: 576px) { + .container { + max-width: 576px + } + } + @media (min-width: 768px) { + .container { + max-width: 768px + } + } + @media (min-width: 992px) { + .container { + max-width: 992px + } + } + @media (min-width: 1200px) { + .container { + max-width: 1200px + } + } + `) +}) + +test('plugins can provide fallbacks to keys missing from the config', () => { + const [components, utilities] = processPlugins({ + borderRadius: { + '1': '1px', + '2': '2px', + '4': '4px', + '8': '8px', + }, + plugins: [ + function({ rule, addComponents, config }) { + addComponents([ + rule('.btn', { + 'border-radius': config('borderRadius.default', '.25rem'), + }), + ]) + }, + ], + }) + + expect(utilities.length).toBe(0) + expect(css(components)).toMatchCss(` + .btn { + border-radius: .25rem + } + `) +}) + +test("the '@' sign is optional in at-rules", () => { + const [components, utilities] = processPlugins({ + plugins: [ + function({ rule, atRule, addComponents }) { + addComponents([ + rule('.card', { + padding: '.5rem', + }), + atRule('media (min-width: 500px)', [ + rule('.card', { + padding: '1rem', + }), + ]), + atRule('@media (min-width: 800px)', [ + rule('.card', { + padding: '1.5rem', + }), + ]), + ]) + }, + ], + }) + + expect(utilities.length).toBe(0) + expect(css(components)).toMatchCss(` + .card { + padding: .5rem + } + @media (min-width: 500px) { + .card { + padding: 1rem + } + } + @media (min-width: 800px) { + .card { + padding: 1.5rem + } + } + `) +}) + +test('variants are optional when adding utilities', () => { + const [, utilities] = processPlugins({ + plugins: [ + function({ rule, addUtilities }) { + addUtilities([ + rule('.border-collapse', { + 'border-collapse': 'collapse', + }), + ]) + }, + ], + }) + + expect(css(utilities)).toMatchCss(` + @variants { + .border-collapse { + border-collapse: collapse + } + } + `) +}) + +test('plugins can add multiple sets of utilities and components', () => { + const [components, utilities] = processPlugins({ + plugins: [ + function({ rule, addUtilities, addComponents }) { + addComponents([ + rule('.card', { + padding: '1rem', + 'border-radius': '.25rem', + }), + ]) + + addUtilities([ + rule('.skew-12deg', { + transform: 'skewY(-12deg)', + }), + ]) + + addComponents([ + rule('.btn', { + padding: '1rem .5rem', + display: 'inline-block', + }), + ]) + + addUtilities([ + rule('.border-collapse', { + 'border-collapse': 'collapse', + }), + ]) + }, + ], + }) + + expect(css(utilities)).toMatchCss(` + @variants { + .skew-12deg { + transform: skewY(-12deg) + } + } + @variants { + .border-collapse { + border-collapse: collapse + } + } + `) + expect(css(components)).toMatchCss(` + .card { + padding: 1rem; + border-radius: .25rem + } + .btn { + padding: 1rem .5rem; + display: inline-block + } + `) +}) + +test("plugins can apply the user's chosen prefix", () => { + const [, utilities] = processPlugins({ + plugins: [ + function({ rule, addUtilities, prefix }) { + addUtilities([ + rule(prefix('.skew-12deg'), { + transform: 'skewY(-12deg)', + }), + ]) + }, + ], + options: { + prefix: 'tw-', + }, + }) + + expect(css(utilities)).toMatchCss(` + @variants { + .tw-skew-12deg { + transform: skewY(-12deg) + } + } + `) +}) + +test('utilities are escaped and automatically respect prefix and important options when created via `utility`', () => { + const [, utilities] = processPlugins({ + plugins: [ + function({ utility, addUtilities }) { + addUtilities([ + utility('.rotate-1/4', { + transform: 'rotate(90deg)', + }), + ]) + }, + ], + options: { + prefix: 'tw-', + important: true, + }, + }) + + expect(css(utilities)).toMatchCss(` + @variants { + .tw-rotate-1\\/4 { + transform: rotate(90deg) !important + } + } + `) +}) + +test("leading '.' is optional when creating utilities via `utility`", () => { + const [, utilities] = processPlugins({ + plugins: [ + function({ utility, addUtilities }) { + addUtilities([ + utility('rotate-1/4', { + transform: 'rotate(90deg)', + }), + ]) + }, + ], + options: { + prefix: 'tw-', + important: true, + }, + }) + + expect(css(utilities)).toMatchCss(` + @variants { + .tw-rotate-1\\/4 { + transform: rotate(90deg) !important + } + } + `) +}) + +test('plugins can choose to make declarations !important', () => { + const [, utilities] = processPlugins({ + plugins: [ + function({ rule, addUtilities, config }) { + addUtilities([ + rule('.skew-12deg', { + transform: `skewY(-12deg)${config('options.important') ? ' !important' : ''}`, + }), + ]) + }, + ], + options: { + important: true, + }, + }) + + expect(css(utilities)).toMatchCss(` + @variants { + .skew-12deg { + transform: skewY(-12deg) !important + } + } + `) +}) diff --git a/css/tailwind.css b/css/tailwind.css index f2078b182200..5881ba48f5f6 100644 --- a/css/tailwind.css +++ b/css/tailwind.css @@ -1,3 +1,5 @@ @tailwind preflight; +@tailwind components; + @tailwind utilities; diff --git a/defaultConfig.stub.js b/defaultConfig.stub.js index d2e5ab7f8277..326620bdec09 100644 --- a/defaultConfig.stub.js +++ b/defaultConfig.stub.js @@ -859,6 +859,22 @@ module.exports = { }, + /* + |----------------------------------------------------------------------------- + | Plugins https://tailwindcss.com/docs/plugins + |----------------------------------------------------------------------------- + | + | Here is where you can register any additional plugins you'd like to use in + | your project. + | + | Be sure to view the complete plugin documentation to learn more about how + | the plugin system works. + | + */ + + plugins: [], + + /* |----------------------------------------------------------------------------- | Advanced Options https://tailwindcss.com/docs/configuration#options diff --git a/src/index.js b/src/index.js index 0b392a1aaf16..2b70f8ca524c 100644 --- a/src/index.js +++ b/src/index.js @@ -5,9 +5,8 @@ import postcss from 'postcss' import perfectionist from 'perfectionist' import registerConfigAsDependency from './lib/registerConfigAsDependency' -import substituteTailwindPreflightAtRule from './lib/substituteTailwindPreflightAtRule' +import substituteTailwindAtRules from './lib/substituteTailwindAtRules' import evaluateTailwindFunctions from './lib/evaluateTailwindFunctions' -import substituteTailwindUtilitiesAtRules from './lib/substituteTailwindUtilitiesAtRules' import substituteVariantsAtRules from './lib/substituteVariantsAtRules' import substituteResponsiveAtRules from './lib/substituteResponsiveAtRules' import substituteScreenAtRules from './lib/substituteScreenAtRules' @@ -34,9 +33,8 @@ const plugin = postcss.plugin('tailwind', config => { return postcss( ...plugins, ...[ - substituteTailwindPreflightAtRule(lazyConfig), + substituteTailwindAtRules(lazyConfig), evaluateTailwindFunctions(lazyConfig), - substituteTailwindUtilitiesAtRules(lazyConfig), substituteVariantsAtRules(lazyConfig), substituteResponsiveAtRules(lazyConfig), substituteScreenAtRules(lazyConfig), diff --git a/src/lib/substituteTailwindAtRules.js b/src/lib/substituteTailwindAtRules.js new file mode 100644 index 000000000000..8fba3d8dfebc --- /dev/null +++ b/src/lib/substituteTailwindAtRules.js @@ -0,0 +1,60 @@ +import fs from 'fs' +import postcss from 'postcss' +import container from '../generators/container' +import utilityModules from '../utilityModules' +import prefixTree from '../util/prefixTree' +import generateModules from '../util/generateModules' +import processPlugins from '../util/processPlugins' + +export default function(config) { + return function(css) { + const unwrappedConfig = config() + + const [pluginComponents, pluginUtilities] = processPlugins(unwrappedConfig) + + css.walkAtRules('tailwind', atRule => { + if (atRule.params === 'preflight') { + atRule.before( + postcss.parse(fs.readFileSync(`${__dirname}/../../css/preflight.css`, 'utf8')) + ) + atRule.remove() + } + + if (atRule.params === 'components') { + const pluginComponentTree = postcss.root({ + nodes: pluginComponents, + }) + + pluginComponentTree.walk(node => (node.source = atRule.source)) + + atRule.before(pluginComponentTree) + atRule.remove() + } + + if (atRule.params === 'utilities') { + const utilities = generateModules(utilityModules, unwrappedConfig.modules, unwrappedConfig) + + if (unwrappedConfig.options.important) { + utilities.walkDecls(decl => (decl.important = true)) + } + + const tailwindUtilityTree = postcss.root({ + nodes: [...container(unwrappedConfig), ...utilities.nodes], + }) + + const pluginUtilityTree = postcss.root({ + nodes: pluginUtilities, + }) + + prefixTree(tailwindUtilityTree, unwrappedConfig.options.prefix) + + tailwindUtilityTree.walk(node => (node.source = atRule.source)) + pluginUtilityTree.walk(node => (node.source = atRule.source)) + + atRule.before(tailwindUtilityTree) + atRule.before(pluginUtilityTree) + atRule.remove() + } + }) + } +} diff --git a/src/lib/substituteTailwindPreflightAtRule.js b/src/lib/substituteTailwindPreflightAtRule.js deleted file mode 100644 index a6a1c59313f1..000000000000 --- a/src/lib/substituteTailwindPreflightAtRule.js +++ /dev/null @@ -1,15 +0,0 @@ -import fs from 'fs' -import postcss from 'postcss' - -export default function() { - return function(css) { - css.walkAtRules('tailwind', atRule => { - if (atRule.params !== 'preflight') { - return - } - - atRule.before(postcss.parse(fs.readFileSync(`${__dirname}/../../css/preflight.css`, 'utf8'))) - atRule.remove() - }) - } -} diff --git a/src/lib/substituteTailwindUtilitiesAtRules.js b/src/lib/substituteTailwindUtilitiesAtRules.js deleted file mode 100644 index d08f4457c7cf..000000000000 --- a/src/lib/substituteTailwindUtilitiesAtRules.js +++ /dev/null @@ -1,34 +0,0 @@ -import postcss from 'postcss' -import applyClassPrefix from '../util/applyClassPrefix' -import generateModules from '../util/generateModules' -import container from '../generators/container' -import utilityModules from '../utilityModules' - -export default function(config) { - return function(css) { - const unwrappedConfig = config() - - css.walkAtRules('tailwind', atRule => { - if (atRule.params !== 'utilities') { - return - } - - const utilities = generateModules(utilityModules, unwrappedConfig.modules, unwrappedConfig) - - if (unwrappedConfig.options.important) { - utilities.walkDecls(decl => (decl.important = true)) - } - - const tailwindClasses = postcss.root({ - nodes: [...container(unwrappedConfig), ...utilities.nodes], - }) - - applyClassPrefix(tailwindClasses, unwrappedConfig.options.prefix) - - tailwindClasses.walk(node => (node.source = atRule.source)) - - atRule.before(tailwindClasses) - atRule.remove() - }) - } -} diff --git a/src/util/applyClassPrefix.js b/src/util/applyClassPrefix.js deleted file mode 100644 index 1855fca914de..000000000000 --- a/src/util/applyClassPrefix.js +++ /dev/null @@ -1,8 +0,0 @@ -export default function(css, prefix) { - const getPrefix = typeof prefix === 'function' ? prefix : () => prefix - - css.walkRules(rule => { - rule.selectors = rule.selectors.map(selector => `.${getPrefix(selector)}${selector.slice(1)}`) - }) - return css -} diff --git a/src/util/prefixSelector.js b/src/util/prefixSelector.js new file mode 100644 index 000000000000..a5133abe794e --- /dev/null +++ b/src/util/prefixSelector.js @@ -0,0 +1,5 @@ +export default function(prefix, selector) { + const getPrefix = typeof prefix === 'function' ? prefix : () => prefix + + return `.${getPrefix(selector)}${selector.slice(1)}` +} diff --git a/src/util/prefixTree.js b/src/util/prefixTree.js new file mode 100644 index 000000000000..44499e9e6cf8 --- /dev/null +++ b/src/util/prefixTree.js @@ -0,0 +1,9 @@ +import prefixSelector from './prefixSelector' + +export default function(css, prefix) { + css.walkRules(rule => { + rule.selectors = rule.selectors.map(selector => prefixSelector(prefix, selector)) + }) + + return css +} diff --git a/src/util/processPlugins.js b/src/util/processPlugins.js new file mode 100644 index 000000000000..b1143a7725bf --- /dev/null +++ b/src/util/processPlugins.js @@ -0,0 +1,70 @@ +import _ from 'lodash' +import postcss from 'postcss' +import escapeClassName from '../util/escapeClassName' +import prefixSelector from '../util/prefixSelector' +import wrapWithVariants from '../util/wrapWithVariants' + +function defineRule(selector, properties) { + const decls = _.map(properties, (value, property) => { + return postcss.decl({ + prop: `${property}`, + value: `${value}`, + }) + }) + + return postcss.rule({ selector }).append(decls) +} + +function defineUtility(selector, properties, options) { + if (selector.startsWith('.')) { + return defineUtility(selector.slice(1), properties, options) + } + + const rule = defineRule( + prefixSelector(options.prefix, `.${escapeClassName(selector)}`), + properties + ) + + if (options.important) { + rule.walkDecls(decl => (decl.important = true)) + } + + return rule +} + +function defineAtRule(atRule, rules) { + const [name, ...params] = atRule.split(' ') + + return postcss + .atRule({ + name: name.startsWith('@') ? name.slice(1) : name, + params: params.join(' '), + }) + .append(rules) +} + +export default function(config) { + const pluginComponents = [] + const pluginUtilities = [] + + config.plugins.forEach(plugin => { + plugin({ + config: (path, defaultValue) => _.get(config, path, defaultValue), + rule: defineRule, + utility: (selector, properties) => defineUtility(selector, properties, config.options), + atRule: defineAtRule, + e: escapeClassName, + addUtilities: (utilities, variants = []) => { + pluginUtilities.push(wrapWithVariants(utilities, variants)) + }, + addComponents: components => { + pluginComponents.push(...components) + }, + prefix: selector => { + return prefixSelector(config.options.prefix, selector) + }, + }) + }) + + return [pluginComponents, pluginUtilities] +}