diff --git a/__tests__/applyAtRule.test.js b/__tests__/applyAtRule.test.js index 2de552e82689..18db716a86a0 100644 --- a/__tests__/applyAtRule.test.js +++ b/__tests__/applyAtRule.test.js @@ -13,7 +13,11 @@ const { utilities: defaultUtilities } = processPlugins( ) function run(input, config = resolvedDefaultConfig, utilities = defaultUtilities) { - return postcss([substituteClassApplyAtRules(config, utilities)]).process(input, { + return postcss([ + substituteClassApplyAtRules(config, () => ({ + utilities, + })), + ]).process(input, { from: undefined, }) } diff --git a/__tests__/applyComplexClasses.test.js b/__tests__/applyComplexClasses.test.js new file mode 100644 index 000000000000..c4dd5ce486b4 --- /dev/null +++ b/__tests__/applyComplexClasses.test.js @@ -0,0 +1,879 @@ +import postcss from 'postcss' +import substituteClassApplyAtRules from '../src/lib/substituteClassApplyAtRules' +import processPlugins from '../src/util/processPlugins' +import resolveConfig from '../src/util/resolveConfig' +import corePlugins from '../src/corePlugins' +import defaultConfig from '../stubs/defaultConfig.stub.js' +import cloneNodes from '../src/util/cloneNodes' + +const resolvedDefaultConfig = resolveConfig([defaultConfig]) + +const defaultProcessedPlugins = processPlugins( + [...corePlugins(resolvedDefaultConfig), ...resolvedDefaultConfig.plugins], + resolvedDefaultConfig +) + +const defaultGetProcessedPlugins = function() { + return { + ...defaultProcessedPlugins, + base: cloneNodes(defaultProcessedPlugins.base), + components: cloneNodes(defaultProcessedPlugins.components), + utilities: cloneNodes(defaultProcessedPlugins.utilities), + } +} + +function run( + input, + config = resolvedDefaultConfig, + getProcessedPlugins = () => + config === resolvedDefaultConfig + ? defaultGetProcessedPlugins() + : processPlugins(corePlugins(config), config) +) { + config.experimental = { + applyComplexClasses: true, + } + return postcss([substituteClassApplyAtRules(config, getProcessedPlugins)]).process(input, { + from: undefined, + }) +} + +test('it copies class declarations into itself', () => { + const output = '.a { color: red; } .b { color: red; }' + + return run('.a { color: red; } .b { @apply a; }').then(result => { + expect(result.css).toEqual(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('selectors with invalid characters do not need to be manually escaped', () => { + const input = ` + .a\\:1\\/2 { color: red; } + .b { @apply a:1/2; } + ` + + const expected = ` + .a\\:1\\/2 { color: red; } + .b { color: red; } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('it removes important from applied classes by default', () => { + const input = ` + .a { color: red !important; } + .b { @apply a; } + ` + + const expected = ` + .a { color: red !important; } + .b { color: red; } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('applied rules can be made !important', () => { + const input = ` + .a { color: red; } + .b { @apply a !important; } + ` + + const expected = ` + .a { color: red; } + .b { color: red !important; } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('cssnext custom property sets are no longer supported', () => { + const input = ` + .a { + color: red; + } + .b { + @apply a --custom-property-set; + } + ` + + return run(input).catch(e => { + expect(e).toMatchObject({ name: 'CssSyntaxError' }) + }) +}) + +test('it fails if the class does not exist', () => { + return run('.b { @apply a; }').catch(e => { + expect(e).toMatchObject({ name: 'CssSyntaxError' }) + }) +}) + +test('applying classes that are defined in a media query is supported', () => { + const input = ` + @media (min-width: 300px) { + .a { color: blue; } + } + + .b { + @apply a; + } + ` + + const output = ` + @media (min-width: 300px) { + .a { color: blue; } + } + @media (min-width: 300px) { + .b { color: blue; } + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('applying classes that are used in a media query is supported', () => { + const input = ` + .a { + color: red; + } + + @media (min-width: 300px) { + .a { color: blue; } + } + + .b { + @apply a; + } + ` + + const output = ` + .a { + color: red; + } + + @media (min-width: 300px) { + .a { color: blue; } + } + + .b { + color: red; + } + + @media (min-width: 300px) { + .b { color: blue; } + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('it matches classes that include pseudo-selectors', () => { + const input = ` + .a:hover { + color: red; + } + + .b { + @apply a; + } + ` + + const output = ` + .a:hover { + color: red; + } + + .b:hover { + color: red; + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('it matches classes that have multiple rules', () => { + const input = ` + .a { + color: red; + } + + .b { + @apply a; + } + + .a { + color: blue; + } + ` + + const output = ` + .a { + color: red; + } + + .b { + color: red; + color: blue; + } + + .a { + color: blue; + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('applying a class that appears multiple times in one selector', () => { + const input = ` + .a + .a > .a { + color: red; + } + + .b { + @apply a; + } + ` + + const output = ` + .a + .a > .a { + color: red; + } + .b + .a > .a { + color: red; + } + .a + .b > .a { + color: red; + } + .a + .a > .b { + color: red; + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(output) + expect(result.warnings().length).toBe(0) + }) +}) + +test('you can apply utility classes that do not actually exist as long as they would exist if utilities were being generated', () => { + const input = ` + .foo { @apply mt-4; } + ` + + const expected = ` + .foo { margin-top: 1rem; } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('the shadow lookup is only used if no @tailwind rules were in the source tree', () => { + const input = ` + @tailwind base; + .foo { @apply mt-4; } + ` + + return run(input).catch(e => { + expect(e).toMatchObject({ name: 'CssSyntaxError' }) + }) +}) + +test('you can apply a class that is defined in multiple rules', () => { + const input = ` + .foo { + color: red; + } + .bar { + @apply foo; + } + .foo { + opacity: .5; + } + ` + const expected = ` + .foo { + color: red; + } + .bar { + color: red; + opacity: .5; + } + .foo { + opacity: .5; + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('you can apply a class that is defined in a media query', () => { + const input = ` + .foo { + @apply sm:text-center; + } + ` + const expected = ` + @media (min-width: 640px) { + .foo { + text-align: center + } + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('you can apply pseudo-class variant utilities', () => { + const input = ` + .foo { + @apply hover:opacity-50; + } + ` + const expected = ` + .foo:hover { + opacity: 0.5 + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('you can apply responsive pseudo-class variant utilities', () => { + const input = ` + .foo { + @apply sm:hover:opacity-50; + } + ` + const expected = ` + @media (min-width: 640px) { + .foo:hover { + opacity: 0.5 + } + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('you can apply the container component', () => { + const input = ` + .foo { + @apply container; + } + ` + const expected = ` + .foo { + width: 100%; + } + @media (min-width: 640px) { + .foo { + max-width: 640px; + } + } + @media (min-width: 768px) { + .foo { + max-width: 768px; + } + } + @media (min-width: 1024px) { + .foo { + max-width: 1024px; + } + } + @media (min-width: 1280px) { + .foo { + max-width: 1280px; + } + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('classes are applied according to CSS source order, not apply order', () => { + const input = ` + .foo { + color: red; + } + .bar { + color: blue; + } + .baz { + @apply bar foo; + } + ` + const expected = ` + .foo { + color: red; + } + .bar { + color: blue; + } + .baz { + color: red; + color: blue; + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('you can apply utilities with multi-class selectors like group-hover variants', () => { + const input = ` + .foo { + @apply group-hover:bar; + } + .bar { + color: blue; + } + .group:hover .group-hover\\:bar { + color: blue; + } + ` + const expected = ` + .group:hover .foo { + color: blue; + } + .bar { + color: blue; + } + .group:hover .group-hover\\:bar { + color: blue; + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('you can apply classes recursively', () => { + const input = ` + .foo { + @apply bar; + } + .bar { + @apply baz; + } + .baz { + color: blue; + } + ` + const expected = ` + .foo { + color: blue; + } + .bar { + color: blue; + } + .baz { + color: blue; + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('applied classes are always inserted before subsequent declarations in the same rule, even if it means moving those subsequent declarations to a new rule', () => { + const input = ` + .foo { + background: blue; + @apply opacity-50 hover:opacity-100 text-right sm:align-middle; + color: red; + } + ` + const expected = ` + .foo { + background: blue; + opacity: 0.5; + } + .foo:hover { + opacity: 1; + } + .foo { + text-align: right; + } + @media (min-width: 640px) { + .foo { + vertical-align: middle; + } + } + .foo { + color: red; + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('adjacent rules are collapsed after being applied', () => { + const input = ` + .foo { + @apply hover:bg-white hover:opacity-50 absolute text-right sm:align-middle sm:text-center; + } + ` + const expected = ` + .foo:hover { + --bg-opacity: 1; + background-color: #fff; + background-color: rgba(255, 255, 255, var(--bg-opacity)); + opacity: 0.5; + } + .foo { + position: absolute; + text-align: right; + } + @media (min-width: 640px) { + .foo { + text-align: center; + vertical-align: middle; + } + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('applying a class applies all instances of that class, even complex selectors', () => { + const input = ` + h1 > p:hover .banana:first-child * { + @apply bar; + } + .bar { + color: blue; + } + @media (print) { + @supports (display: grid) { + .baz .bar:hover { + text-align: right; + float: left; + } + } + } + ` + const expected = ` + h1 > p:hover .banana:first-child * { + color: blue; + } + @media (print) { + @supports (display: grid) { + .baz h1 > p:hover .banana:first-child *:hover { + text-align: right; + float: left; + } + } + } + .bar { + color: blue; + } + @media (print) { + @supports (display: grid) { + .baz .bar:hover { + text-align: right; + float: left; + } + } + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test('you can apply classes to rules within at-rules', () => { + const input = ` + @supports (display: grid) { + .baz .bar { + @apply text-right float-left hover:opacity-50 md:float-right; + } + } + ` + const expected = ` + @supports (display: grid) { + .baz .bar { + float: left; + } + .baz .bar:hover { + opacity: 0.5; + } + .baz .bar { + text-align: right; + } + @media (min-width: 768px) { + .baz .bar { + float: right; + } + } + } + ` + + return run(input).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +describe('using apply with the prefix option', () => { + test('applying a class including the prefix', () => { + const input = ` + .foo { @apply tw-mt-4; } + ` + + const expected = ` + .foo { margin-top: 1rem; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: 'tw-', + }, + ]) + + return run(input, config).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) + }) + + test('applying a class including the prefix when using a prefix function', () => { + const input = ` + .foo { @apply tw-func-mt-4; } + ` + + const expected = ` + .foo { margin-top: 1rem; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: () => { + return 'tw-func-' + }, + }, + ]) + + return run(input, config).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) + }) + + test('applying a class without the prefix fails', () => { + const input = ` + .foo { @apply mt-4; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: 'tw-', + }, + ]) + + return run(input, config).catch(e => { + expect(e).toMatchObject({ name: 'CssSyntaxError' }) + }) + }) + + test('custom classes with no prefix can be applied', () => { + const input = ` + .foo { @apply mt-4; } + .mt-4 { color: red; } + ` + + const expected = ` + .foo { color: red; } + .mt-4 { color: red; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: 'tw-', + }, + ]) + + return run(input, config).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) + }) + + test('built-in prefixed utilities can be extended and applied', () => { + const input = ` + .foo { @apply tw-mt-4; } + .tw-mt-4 { color: red; } + ` + + const expected = ` + .foo { margin-top: 1rem; color: red; } + .tw-mt-4 { color: red; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: 'tw-', + }, + ]) + + return run(input, config).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) + }) + + test('a helpful error message is provided if it appears the user forgot to include their prefix', () => { + const input = ` + .foo { @apply mt-4; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: 'tw-', + }, + ]) + + expect.assertions(1) + + return run(input, config).catch(e => { + expect(e).toMatchObject({ + name: 'CssSyntaxError', + reason: 'The `mt-4` class does not exist, but `tw-mt-4` does. Did you forget the prefix?', + }) + }) + }) + + test('you can apply classes with important and a prefix enabled', () => { + const input = ` + .foo { @apply tw-mt-4; } + ` + + const expected = ` + .foo { margin-top: 1rem; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: 'tw-', + important: true, + }, + ]) + + return run(input, config).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) + }) + + test('you can apply classes with an important selector and a prefix enabled', () => { + const input = ` + .foo { @apply tw-mt-4; } + ` + + const expected = ` + .foo { margin-top: 1rem; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: 'tw-', + important: '#app', + }, + ]) + + return run(input, config).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) + }) +}) + +test('you can apply utility classes when a selector is used for the important option', () => { + const input = ` + .foo { + @apply mt-8 mb-8; + } + ` + + const expected = ` + .foo { + margin-top: 2rem; + margin-bottom: 2rem; + } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + important: '#app', + }, + ]) + + return run(input, config).then(result => { + expect(result.css).toMatchCss(expected) + expect(result.warnings().length).toBe(0) + }) +}) diff --git a/__tests__/fixtures/tailwind-input.css b/__tests__/fixtures/tailwind-input.css index 91bb3eebdab4..c5c8767ffe5b 100644 --- a/__tests__/fixtures/tailwind-input.css +++ b/__tests__/fixtures/tailwind-input.css @@ -6,7 +6,7 @@ @responsive { .example { - @apply .font-bold; + @apply font-bold; color: theme('colors.red.500'); } } diff --git a/__tests__/processPlugins.test.js b/__tests__/processPlugins.test.js index 5ceea53e0a76..da369bde8122 100644 --- a/__tests__/processPlugins.test.js +++ b/__tests__/processPlugins.test.js @@ -979,64 +979,46 @@ test('plugins can add multiple sets of utilities and components', () => { }) test('plugins respect prefix and important options by default when adding utilities', () => { - const { utilities } = processPlugins( - [ - function({ addUtilities }) { - addUtilities({ - '.rotate-90': { - transform: 'rotate(90deg)', - }, - }) - }, - ], - makeConfig({ + return _postcss([ + tailwind({ prefix: 'tw-', important: true, - }) - ) - - expect(css(utilities)).toMatchCss(` - @layer utilities { - @variants { + corePlugins: [], + plugins: [ + function({ addUtilities }) { + addUtilities({ + '.rotate-90': { + transform: 'rotate(90deg)', + }, + }) + }, + ], + }), + ]) + .process( + ` + @tailwind utilities; + `, + { from: undefined } + ) + .then(result => { + const expected = ` .tw-rotate-90 { transform: rotate(90deg) !important } - } - } - `) -}) + ` -test('when important is a selector it is used to scope utilities instead of adding !important', () => { - const { utilities } = processPlugins( - [ - function({ addUtilities }) { - addUtilities({ - '.rotate-90': { - transform: 'rotate(90deg)', - }, - }) - }, - ], - makeConfig({ - important: '#app', + expect(result.css).toMatchCss(expected) }) - ) - - expect(css(utilities)).toMatchCss(` - @layer utilities { - @variants { - #app .rotate-90 { - transform: rotate(90deg) - } - } - } - `) }) -test('when important contains a class an error is thrown', () => { - expect(() => { - processPlugins( - [ +test('when important is a selector it is used to scope utilities instead of adding !important', () => { + return _postcss([ + tailwind({ + prefix: 'tw-', + important: '#app', + corePlugins: [], + plugins: [ function({ addUtilities }) { addUtilities({ '.rotate-90': { @@ -1045,65 +1027,89 @@ test('when important contains a class an error is thrown', () => { }) }, ], - makeConfig({ - important: '#app .project', - }) + }), + ]) + .process( + ` + @tailwind utilities; + `, + { from: undefined } ) - }).toThrow() + .then(result => { + const expected = ` + #app .tw-rotate-90 { + transform: rotate(90deg) + } + ` + + expect(result.css).toMatchCss(expected) + }) }) test('when important is a selector it scopes all selectors in a rule, even though defining utilities like this is stupid', () => { - const { utilities } = processPlugins( - [ - function({ addUtilities }) { - addUtilities({ - '.rotate-90, .rotate-1\\/4': { - transform: 'rotate(90deg)', - }, - }) - }, - ], - makeConfig({ + return _postcss([ + tailwind({ important: '#app', - }) - ) - - expect(css(utilities)).toMatchCss(` - @layer utilities { - @variants { + corePlugins: [], + plugins: [ + function({ addUtilities }) { + addUtilities({ + '.rotate-90, .rotate-1\\/4': { + transform: 'rotate(90deg)', + }, + }) + }, + ], + }), + ]) + .process( + ` + @tailwind utilities; + `, + { from: undefined } + ) + .then(result => { + const expected = ` #app .rotate-90, #app .rotate-1\\/4 { transform: rotate(90deg) } - } - } - `) + ` + + expect(result.css).toMatchCss(expected) + }) }) test('important utilities are not made double important when important option is used', () => { - const { utilities } = processPlugins( - [ - function({ addUtilities }) { - addUtilities({ - '.rotate-90': { - transform: 'rotate(90deg) !important', - }, - }) - }, - ], - makeConfig({ + return _postcss([ + tailwind({ important: true, - }) - ) - - expect(css(utilities)).toMatchCss(` - @layer utilities { - @variants { + corePlugins: [], + plugins: [ + function({ addUtilities }) { + addUtilities({ + '.rotate-90': { + transform: 'rotate(90deg) !important', + }, + }) + }, + ], + }), + ]) + .process( + ` + @tailwind utilities; + `, + { from: undefined } + ) + .then(result => { + const expected = ` .rotate-90 { transform: rotate(90deg) !important } - } - } - `) + ` + + expect(result.css).toMatchCss(expected) + }) }) test("component declarations respect the 'prefix' option by default", () => { @@ -1346,69 +1352,79 @@ test("plugins can apply the user's chosen prefix to components manually", () => }) test('prefix can optionally be ignored for utilities', () => { - const { utilities } = processPlugins( - [ - function({ addUtilities }) { - addUtilities( - { - '.rotate-90': { - transform: 'rotate(90deg)', - }, - }, - { - respectPrefix: false, - } - ) - }, - ], - makeConfig({ + return _postcss([ + tailwind({ prefix: 'tw-', - important: true, - }) - ) - - expect(css(utilities)).toMatchCss(` - @layer utilities { - @variants { + corePlugins: [], + plugins: [ + function({ addUtilities }) { + addUtilities( + { + '.rotate-90': { + transform: 'rotate(90deg)', + }, + }, + { + respectPrefix: false, + } + ) + }, + ], + }), + ]) + .process( + ` + @tailwind utilities; + `, + { from: undefined } + ) + .then(result => { + const expected = ` .rotate-90 { - transform: rotate(90deg) !important + transform: rotate(90deg) } - } - } - `) + ` + + expect(result.css).toMatchCss(expected) + }) }) test('important can optionally be ignored for utilities', () => { - const { utilities } = processPlugins( - [ - function({ addUtilities }) { - addUtilities( - { - '.rotate-90': { - transform: 'rotate(90deg)', - }, - }, - { - respectImportant: false, - } - ) - }, - ], - makeConfig({ - prefix: 'tw-', + return _postcss([ + tailwind({ important: true, - }) - ) - - expect(css(utilities)).toMatchCss(` - @layer utilities { - @variants { - .tw-rotate-90 { + corePlugins: [], + plugins: [ + function({ addUtilities }) { + addUtilities( + { + '.rotate-90': { + transform: 'rotate(90deg)', + }, + }, + { + respectImportant: false, + } + ) + }, + ], + }), + ]) + .process( + ` + @tailwind utilities; + `, + { from: undefined } + ) + .then(result => { + const expected = ` + .rotate-90 { transform: rotate(90deg) } - } - } - `) + ` + + expect(result.css).toMatchCss(expected) + }) }) test('variants can still be specified when ignoring prefix and important options', () => { diff --git a/jest/customMatchers.js b/jest/customMatchers.js index 23372befcd2a..09fd808c9b88 100644 --- a/jest/customMatchers.js +++ b/jest/customMatchers.js @@ -3,7 +3,7 @@ expect.extend({ // This is probably naive but it's fast and works well enough. toMatchCss(received, argument) { function stripped(str) { - return str.replace(/\s/g, '') + return str.replace(/\s/g, '').replace(/;/g, '') } if (stripped(received) === stripped(argument)) { diff --git a/src/featureFlags.js b/src/featureFlags.js index 485dc07232a9..9e8895045096 100644 --- a/src/featureFlags.js +++ b/src/featureFlags.js @@ -8,6 +8,7 @@ const featureFlags = { 'extendedSpacingScale', 'defaultLineHeights', 'extendedFontSizeScale', + 'applyComplexClasses', ], } @@ -69,8 +70,7 @@ export function issueFlagNotices(config) { .map(s => chalk.cyan(s)) .join(', ') - console.log() - log.info(`You have opted-in to future-facing breaking changes: ${changes}`) + log.info(`\nYou have opted-in to future-facing breaking changes: ${changes}`) log.info( 'These changes are stable and will be the default behavior in the next major version of Tailwind.' ) @@ -81,8 +81,7 @@ export function issueFlagNotices(config) { .map(s => chalk.yellow(s)) .join(', ') - console.log() - log.warn(`You have enabled experimental features: ${changes}`) + log.warn(`\nYou have enabled experimental features: ${changes}`) log.warn( 'Experimental features are not covered by semver, may introduce breaking changes, and can change at any time.' ) @@ -93,8 +92,7 @@ export function issueFlagNotices(config) { .map(s => chalk.magenta(s)) .join(', ') - console.log() - log.risk(`There are upcoming breaking changes: ${changes}`) + log.risk(`\nThere are upcoming breaking changes: ${changes}`) log.risk( 'We highly recommend opting-in to these changes now to simplify upgrading Tailwind in the future.' ) diff --git a/src/flagged/applyComplexClasses.js b/src/flagged/applyComplexClasses.js new file mode 100644 index 000000000000..9b1d7e8f706d --- /dev/null +++ b/src/flagged/applyComplexClasses.js @@ -0,0 +1,268 @@ +import _ from 'lodash' +import selectorParser from 'postcss-selector-parser' +import postcss from 'postcss' +import substituteTailwindAtRules from '../lib/substituteTailwindAtRules' +import evaluateTailwindFunctions from '../lib/evaluateTailwindFunctions' +import substituteVariantsAtRules from '../lib/substituteVariantsAtRules' +import substituteResponsiveAtRules from '../lib/substituteResponsiveAtRules' +import convertLayerAtRulesToControlComments from '../lib/convertLayerAtRulesToControlComments' +import substituteScreenAtRules from '../lib/substituteScreenAtRules' +import prefixSelector from '../util/prefixSelector' + +function hasAtRule(css, atRule) { + let foundAtRule = false + + css.walkAtRules(atRule, () => { + foundAtRule = true + return false + }) + + return foundAtRule +} + +function generateRulesFromApply({ rule, utilityName: className, classPosition }, replaceWith) { + const processedSelectors = rule.selectors.map(selector => { + const processor = selectorParser(selectors => { + let i = 0 + selectors.walkClasses(c => { + if (c.value === className && classPosition === i) { + c.replaceWith(selectorParser.attribute({ attribute: '__TAILWIND-APPLY-PLACEHOLDER__' })) + } + i++ + }) + }) + + // You could argue we should make this replacement at the AST level, but if we believe + // the placeholder string is safe from collisions then it is safe to do this is a simple + // string replacement, and much, much faster. + const processedSelector = processor + .processSync(selector) + .replace('[__TAILWIND-APPLY-PLACEHOLDER__]', replaceWith) + + return processedSelector + }) + + const cloned = rule.clone() + let current = cloned + let parent = rule.parent + + while (parent && parent.type !== 'root') { + const parentClone = parent.clone() + parentClone.removeAll() + parentClone.append(current) + current.parent = parentClone + current = parentClone + parent = parent.parent + } + + cloned.selectors = processedSelectors + return current +} + +function extractUtilityNames(selector) { + const processor = selectorParser(selectors => { + let classes = [] + + selectors.walkClasses(c => { + classes.push(c) + }) + + return classes.map(c => c.value) + }) + + return processor.transformSync(selector) +} + +function buildUtilityMap(css) { + let index = 0 + const utilityMap = {} + + css.walkRules(rule => { + const utilityNames = extractUtilityNames(rule.selector) + + utilityNames.forEach((utilityName, i) => { + if (utilityMap[utilityName] === undefined) { + utilityMap[utilityName] = [] + } + + utilityMap[utilityName].push({ + index, + utilityName, + classPosition: i, + rule: rule.clone({ parent: rule.parent }), + containsApply: hasAtRule(rule, 'apply'), + }) + index++ + }) + }) + + return utilityMap +} + +function mergeAdjacentRules(initialRule, rulesToInsert) { + let previousRule = initialRule + + rulesToInsert.forEach(toInsert => { + if ( + toInsert.type === 'rule' && + previousRule.type === 'rule' && + toInsert.selector === previousRule.selector + ) { + previousRule.append(toInsert.nodes) + } else if ( + toInsert.type === 'atrule' && + previousRule.type === 'atrule' && + toInsert.params === previousRule.params + ) { + const merged = mergeAdjacentRules( + previousRule.nodes[previousRule.nodes.length - 1], + toInsert.nodes + ) + + previousRule.append(merged) + } else { + previousRule = toInsert + } + + toInsert.walk(n => { + if (n.nodes && n.nodes.length === 0) { + n.remove() + } + }) + }) + + return rulesToInsert.filter(r => r.nodes.length > 0) +} + +function makeExtractUtilityRules(css, config) { + const utilityMap = buildUtilityMap(css) + const orderUtilityMap = _.fromPairs( + _.flatMap(_.toPairs(utilityMap), ([_utilityName, utilities]) => { + return utilities.map(utility => { + return [utility.index, utility] + }) + }) + ) + return function(utilityNames, rule) { + return _.flatMap(utilityNames, utilityName => { + if (utilityMap[utilityName] === undefined) { + // Look for prefixed utility in case the user has goofed + const prefixedUtility = prefixSelector(config.prefix, `.${utilityName}`).slice(1) + + if (utilityMap[prefixedUtility] !== undefined) { + throw rule.error( + `The \`${utilityName}\` class does not exist, but \`${prefixedUtility}\` does. Did you forget the prefix?` + ) + } + + throw rule.error( + `The \`${utilityName}\` class does not exist. If you're sure that \`${utilityName}\` exists, make sure that any \`@import\` statements are being properly processed before Tailwind CSS sees your CSS, as \`@apply\` can only be used for classes in the same CSS tree.`, + { word: utilityName } + ) + } + return utilityMap[utilityName].map(({ index }) => index) + }) + .sort((a, b) => a - b) + .map(i => orderUtilityMap[i]) + } +} + +function processApplyAtRules(css, lookupTree, config) { + const extractUtilityRules = makeExtractUtilityRules(lookupTree, config) + + while (hasAtRule(css, 'apply')) { + css.walkRules(rule => { + const applyRules = [] + + // Only walk direct children to avoid issues with nesting plugins + rule.each(child => { + if (child.type === 'atrule' && child.name === 'apply') { + applyRules.unshift(child) + } + }) + + applyRules.forEach(applyRule => { + const [ + importantEntries, + applyUtilityNames, + important = importantEntries.length > 0, + ] = _.partition(applyRule.params.split(' '), n => n === '!important') + + const currentUtilityNames = extractUtilityNames(rule.selector) + + if (_.intersection(applyUtilityNames, currentUtilityNames).length > 0) { + const currentUtilityName = _.intersection(applyUtilityNames, currentUtilityNames)[0] + throw rule.error( + `You cannot \`@apply\` the \`${currentUtilityName}\` utility here because it creates a circular dependency.` + ) + } + + // Extract any post-apply declarations and re-insert them after apply rules + const afterRule = rule.clone({ raws: {} }) + afterRule.nodes = afterRule.nodes.slice(rule.index(applyRule) + 1) + rule.nodes = rule.nodes.slice(0, rule.index(applyRule) + 1) + + // Sort applys to match CSS source order + const applys = extractUtilityRules(applyUtilityNames, applyRule) + + // Get new rules with the utility portion of the selector replaced with the new selector + const rulesToInsert = [ + ...applys.map(applyUtility => { + return generateRulesFromApply(applyUtility, rule.selector) + }), + afterRule, + ] + + const { nodes } = _.tap(postcss.root({ nodes: rulesToInsert }), root => + root.walkDecls(d => (d.important = important)) + ) + + const mergedRules = mergeAdjacentRules(rule, nodes) + + applyRule.remove() + rule.after(mergedRules) + }) + + // If the base rule has nothing in it (all applys were pseudo or responsive variants), + // remove the rule fuggit. + if (rule.nodes.length === 0) { + rule.remove() + } + }) + } + + return css +} + +export default function applyComplexClasses(config, getProcessedPlugins) { + return function(css) { + // Tree already contains @tailwind rules, don't prepend default Tailwind tree + if (hasAtRule(css, 'tailwind')) { + return processApplyAtRules(css, css, config) + } + + // Tree contains no @tailwind rules, so generate all of Tailwind's styles and + // prepend them to the user's CSS. Important for