Add initial support for applying variants and other complex classes

This commit is contained in:
Adam Wathan 2020-08-12 19:47:21 -04:00
parent 1791768a56
commit 36fc03b16d
4 changed files with 563 additions and 0 deletions

View File

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

View File

@ -8,6 +8,7 @@ const featureFlags = {
'extendedSpacingScale',
'defaultLineHeights',
'extendedFontSizeScale',
'applyComplexClasses',
],
}

View File

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

View File

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