From 36fc03b16d0decaaefcaf42d6b3ff2bbe6cc1131 Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Wed, 12 Aug 2020 19:47:21 -0400 Subject: [PATCH] Add initial support for applying variants and other complex classes --- __tests__/applyComplexClasses.test.js | 348 +++++++++++++++++++++++++ src/featureFlags.js | 1 + src/flagged/applyComplexClasses.js | 207 +++++++++++++++ src/lib/substituteClassApplyAtRules.js | 7 + 4 files changed, 563 insertions(+) create mode 100644 __tests__/applyComplexClasses.test.js create mode 100644 src/flagged/applyComplexClasses.js diff --git a/__tests__/applyComplexClasses.test.js b/__tests__/applyComplexClasses.test.js new file mode 100644 index 000000000..ce6a31981 --- /dev/null +++ b/__tests__/applyComplexClasses.test.js @@ -0,0 +1,348 @@ +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' + +const resolvedDefaultConfig = resolveConfig([defaultConfig]) + +const { utilities: defaultUtilities } = processPlugins( + corePlugins(resolvedDefaultConfig), + resolvedDefaultConfig +) + +function run(input, config = resolvedDefaultConfig, utilities = defaultUtilities) { + config.experimental = { + applyComplexClasses: true, + } + return postcss([substituteClassApplyAtRules(config, utilities)]).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).toEqual(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test.skip('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).toEqual(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test.skip('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).toEqual(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test.skip('cssnext custom property sets are preserved', () => { + const input = ` + .a { + color: red; + } + .b { + @apply a --custom-property-set; + } + ` + + const expected = ` + .a { + color: red; + } + .b { + color: red; + @apply --custom-property-set; + } + ` + + return run(input).then(result => { + expect(result.css).toEqual(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +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.skip('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).toEqual(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test.skip('you can apply utility classes without using the given prefix', () => { + const input = ` + .foo { @apply .tw-mt-4 .mb-4; } + ` + + const expected = ` + .foo { margin-top: 1rem; margin-bottom: 1rem; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: 'tw-', + }, + ]) + + return run(input, config, processPlugins(corePlugins(config), config).utilities).then(result => { + expect(result.css).toEqual(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test.skip('you can apply utility classes without using the given prefix when using a function for the prefix', () => { + const input = ` + .foo { @apply .tw-mt-4 .mb-4; } + ` + + const expected = ` + .foo { margin-top: 1rem; margin-bottom: 1rem; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: () => { + return 'tw-' + }, + }, + ]) + + return run(input, config, processPlugins(corePlugins(config), config).utilities).then(result => { + expect(result.css).toEqual(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test.skip('you can apply utility classes without specificity prefix even if important (selector) is used', () => { + 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, processPlugins(corePlugins(config), config).utilities).then(result => { + expect(result.css).toEqual(expected) + expect(result.warnings().length).toBe(0) + }) +}) + +test.skip('you can apply utility classes without using the given prefix even if important (selector) is used', () => { + const input = ` + .foo { @apply .tw-mt-4 .mb-4; } + ` + + const expected = ` + .foo { margin-top: 1rem; margin-bottom: 1rem; } + ` + + const config = resolveConfig([ + { + ...defaultConfig, + prefix: 'tw-', + important: '#app', + }, + ]) + + return run(input, config, processPlugins(corePlugins(config), config).utilities).then(result => { + expect(result.css).toEqual(expected) + expect(result.warnings().length).toBe(0) + }) +}) diff --git a/src/featureFlags.js b/src/featureFlags.js index 485dc0723..3c881e6a1 100644 --- a/src/featureFlags.js +++ b/src/featureFlags.js @@ -8,6 +8,7 @@ const featureFlags = { 'extendedSpacingScale', 'defaultLineHeights', 'extendedFontSizeScale', + 'applyComplexClasses', ], } diff --git a/src/flagged/applyComplexClasses.js b/src/flagged/applyComplexClasses.js new file mode 100644 index 000000000..f15be919d --- /dev/null +++ b/src/flagged/applyComplexClasses.js @@ -0,0 +1,207 @@ +import _ from 'lodash' +import selectorParser from 'postcss-selector-parser' + +function applyUtility(rule, className, replaceWith) { + const selectors = rule.selectors.map(selector => { + const processor = selectorParser(selectors => { + selectors.walkClasses(c => { + if (c.value === className) { + c.replaceWith(selectorParser.attribute({ attribute: '__TAILWIND-APPLY-PLACEHOLDER__' })) + } + }) + }) + + // 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 = selectors + 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 => { + if (utilityMap[utilityName] === undefined) { + utilityMap[utilityName] = [] + } + + utilityMap[utilityName].push({ + index, + utilityName, + rule: rule.clone({ parent: rule.parent }), + containsApply: hasInject(rule), + }) + 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) { + const utilityMap = buildUtilityMap(css) + const orderUtilityMap = Object.fromEntries( + Object.entries(utilityMap).flatMap(([utilityName, utilities]) => { + return utilities.map(utility => { + return [utility.index, utility] + }) + }) + ) + return function(utilityNames, rule) { + return utilityNames + .flatMap(utilityName => { + if (utilityMap[utilityName] === undefined) { + throw rule.error( + `The \`${utilityName}\` utility 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 hasInject(css) { + let foundInject = false + + css.walkAtRules('apply', () => { + foundInject = true + return false + }) + + return foundInject +} + +export default function applyComplexClasses(css) { + const extractUtilityRules = makeExtractUtilityRules(css) + + while (hasInject(css)) { + css.walkRules(rule => { + const injectRules = [] + + // Only walk direct children to avoid issues with nesting plugins + rule.each(child => { + if (child.type === 'atrule' && child.name === 'apply') { + injectRules.unshift(child) + } + }) + + injectRules.forEach(inject => { + const injectUtilityNames = inject.params.split(' ') + const currentUtilityNames = extractUtilityNames(rule.selector) + + if (_.intersection(injectUtilityNames, currentUtilityNames).length > 0) { + const currentUtilityName = _.intersection(injectUtilityNames, currentUtilityNames)[0] + throw rule.error( + `You cannot \`@apply\` the \`${currentUtilityName}\` utility here because it creates a circular dependency.` + ) + } + + // Extract any post-inject declarations and re-insert them after inject rules + const afterRule = rule.clone({ raws: {} }) + afterRule.nodes = afterRule.nodes.slice(rule.index(inject) + 1) + rule.nodes = rule.nodes.slice(0, rule.index(inject) + 1) + + // Sort injects to match CSS source order + const injects = extractUtilityRules(injectUtilityNames, inject) + + // Get new rules with the utility portion of the selector replaced with the new selector + const rulesToInsert = [ + ...injects.map(injectUtility => { + return applyUtility(injectUtility.rule, injectUtility.utilityName, rule.selector) + }), + afterRule, + ] + + const mergedRules = mergeAdjacentRules(rule, rulesToInsert) + + inject.remove() + rule.after(mergedRules) + }) + + // If the base rule has nothing in it (all injects were pseudo or responsive variants), + // remove the rule fuggit. + if (rule.nodes.length === 0) { + rule.remove() + } + }) + } + + return css +} diff --git a/src/lib/substituteClassApplyAtRules.js b/src/lib/substituteClassApplyAtRules.js index 59439e87b..1a3e74e1c 100644 --- a/src/lib/substituteClassApplyAtRules.js +++ b/src/lib/substituteClassApplyAtRules.js @@ -4,6 +4,9 @@ import escapeClassName from '../util/escapeClassName' import prefixSelector from '../util/prefixSelector' import increaseSpecificity from '../util/increaseSpecificity' +import { flagEnabled } from '../featureFlags' +import applyComplexClasses from '../flagged/applyComplexClasses' + function buildClassTable(css) { const classTable = {} @@ -54,6 +57,10 @@ function findClass(classToApply, classTable, onError) { } export default function(config, generatedUtilities) { + if (flagEnabled(config, 'applyComplexClasses')) { + return applyComplexClasses + } + return function(css) { const classLookup = buildClassTable(css) const shadowLookup = buildShadowTable(generatedUtilities)