From a1246de6a5cb1980155af797753d250a5037ca9f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 8 Dec 2022 15:07:06 +0100 Subject: [PATCH] WIP: New `@apply` logic use `lightningcss` and `prettier` for `toMatchFormattedCss` Lightning CSS applies some optimizations and prettier ensures that the output is always formatted in the same way. In a future version, we will be using Lightning CSS to apply optimizations like handling the nesting, optimizing selectors, sorting declarations, simplifying colors and so on. To guarantee that we have the same output in both the normal version and OXIDE version, we can apply lightningcss in our tests so that it always produces correct results in both version with the same optimizations applied. This also aliases `toMatchCss` to `toMatchFormattedCss` so that we can guarantee the same output. This has a small caveat where `lightningcss` is pretty strict. It will complain about `@media screen(md) {}` for example. This is correct because it is not valid CSS, but we eventually take care of it. In this case, we will catch the error and use prettier instead (since this should only happen in tests that doesn't use end-to-end tests, but uses a more specialized test instead). If prettier still finds an error, we will ensure to throw the original error. Eventually, all the "lower" level tests, should probably become higher level end-to-end tests. But let's do everything step by step! --- jest/customMatchers.js | 193 +- oxide/crates/core/src/parser.rs | 14 +- src/corePlugins.js | 41 +- src/lib/generateRules.js | 41 +- src/lib/setupContextUtils.js | 5 + src/oxide/cli/build/deps.ts | 1 + src/oxide/core/expand-apply-at-rules.ts | 126 + src/oxide/core/resolve-matches.ts | 829 +++++++ src/plugin.js | 41 + src/processTailwindFeatures.js | 28 +- src/util/parseObjectStyles.js | 10 +- tests/apply.test.js | 2911 ++++++++++++----------- tests/arbitrary-values.test.css | 8 +- tests/arbitrary-variants.test.js | 238 +- tests/basic-usage.test.js | 13 +- tests/detect-nesting.test.js | 211 +- tests/generate-rules.test.js | 16 + tests/modify-selectors.test.js | 6 +- tests/oxide.apply.test.js | 484 ++++ tests/oxide.test.js | 65 +- tests/parseObjectStyles.test.js | 27 +- tests/preflight.test.js | 9 +- tests/syntax-lit-html.test.js | 2 +- tests/variants.test.js | 16 +- 24 files changed, 3444 insertions(+), 1891 deletions(-) create mode 100644 src/oxide/core/expand-apply-at-rules.ts create mode 100644 src/oxide/core/resolve-matches.ts create mode 100644 tests/oxide.apply.test.js diff --git a/jest/customMatchers.js b/jest/customMatchers.js index 385faf7bd..15b03d764 100644 --- a/jest/customMatchers.js +++ b/jest/customMatchers.js @@ -1,58 +1,105 @@ const prettier = require('prettier') const { diff } = require('jest-diff') +const lightningcss = require('lightningcss') -function format(input) { +function formatPrettier(input) { return prettier.format(input, { parser: 'css', printWidth: 100, }) } +function toMatchFormattedCss(received = '', argument = '') { + function format(input) { + try { + let output = lightningcss + .transform({ + filename: 'input.css', + code: Buffer.from(formatPrettier(input.replace(/\n/g, ''))), + minify: false, + targets: { chrome: 106 << 16 }, + drafts: { + nesting: true, + customMedia: true, + }, + }) + .code.toString('utf8') + + return formatPrettier(output) + } catch (err) { + try { + // Lightning CSS is pretty strict, so it will fail for `@media screen(md) {}` for example, + // in that case we can fallback to prettier since it doesn't really care. However if an + // actual sytnax is made, then we still want to show the proper error. + return formatPrettier(input.replace(/\n/g, '')) + } catch { + let lines = err.source.split('\n') + let e = new Error( + [ + 'Error formatting using Lightning CSS:', + '', + ...[ + '```css', + ...lines.slice(Math.max(err.loc.line - 3, 0), err.loc.line), + ' '.repeat(err.loc.column - 1) + '^-- ' + err.toString(), + ...lines.slice(err.loc.line, err.loc.line + 2), + '```', + ], + ].join('\n') + ) + if (Error.captureStackTrace) { + Error.captureStackTrace(e, toMatchFormattedCss) + } + throw e + } + } + } + const options = { + comment: 'formatCSS(received) === formatCSS(argument)', + isNot: this.isNot, + promise: this.promise, + } + + let formattedReceived = format(received) + let formattedArgument = format(argument) + + const pass = formattedReceived === formattedArgument + + const message = pass + ? () => { + return ( + this.utils.matcherHint('toMatchFormattedCss', undefined, undefined, options) + + '\n\n' + + `Expected: not ${this.utils.printExpected(formattedReceived)}\n` + + `Received: ${this.utils.printReceived(formattedArgument)}` + ) + } + : () => { + const actual = formattedReceived + const expected = formattedArgument + + const diffString = diff(expected, actual, { + expand: this.expand, + }) + + return ( + this.utils.matcherHint('toMatchFormattedCss', undefined, undefined, options) + + '\n\n' + + (diffString && diffString.includes('- Expect') + ? `Difference:\n\n${diffString}` + : `Expected: ${this.utils.printExpected(expected)}\n` + + `Received: ${this.utils.printReceived(actual)}`) + ) + } + + return { actual: received, message, pass } +} + expect.extend({ // Compare two CSS strings with all whitespace removed // This is probably naive but it's fast and works well enough. - toMatchCss(received, argument) { - function stripped(str) { - return str.replace(/\s/g, '').replace(/;/g, '') - } - - const options = { - comment: 'stripped(received) === stripped(argument)', - isNot: this.isNot, - promise: this.promise, - } - - const pass = stripped(received) === stripped(argument) - - const message = pass - ? () => { - return ( - this.utils.matcherHint('toMatchCss', undefined, undefined, options) + - '\n\n' + - `Expected: not ${this.utils.printExpected(format(received))}\n` + - `Received: ${this.utils.printReceived(format(argument))}` - ) - } - : () => { - const actual = format(received) - const expected = format(argument) - - const diffString = diff(expected, actual, { - expand: this.expand, - }) - - return ( - this.utils.matcherHint('toMatchCss', undefined, undefined, options) + - '\n\n' + - (diffString && diffString.includes('- Expect') - ? `Difference:\n\n${diffString}` - : `Expected: ${this.utils.printExpected(expected)}\n` + - `Received: ${this.utils.printReceived(actual)}`) - ) - } - - return { actual: received, message, pass } - }, + toMatchCss: toMatchFormattedCss, + toMatchFormattedCss, toIncludeCss(received, argument) { function stripped(str) { return str.replace('/* prettier-ignore */', '').replace(/\s/g, '').replace(/;/g, '') @@ -71,13 +118,13 @@ expect.extend({ return ( this.utils.matcherHint('toIncludeCss', undefined, undefined, options) + '\n\n' + - `Expected: not ${this.utils.printExpected(format(received))}\n` + - `Received: ${this.utils.printReceived(format(argument))}` + `Expected: not ${this.utils.printExpected(formatPrettier(received))}\n` + + `Received: ${this.utils.printReceived(formatPrettier(argument))}` ) } : () => { - const actual = format(received) - const expected = format(argument) + const actual = formatPrettier(received) + const expected = formatPrettier(argument) const diffString = diff(expected, actual, { expand: this.expand, @@ -96,55 +143,3 @@ expect.extend({ return { actual: received, message, pass } }, }) - -expect.extend({ - // Compare two CSS strings with all whitespace removed - // This is probably naive but it's fast and works well enough. - toMatchFormattedCss(received = '', argument = '') { - function format(input) { - return prettier.format(input.replace(/\n/g, ''), { - parser: 'css', - printWidth: 100, - }) - } - const options = { - comment: 'stripped(received) === stripped(argument)', - isNot: this.isNot, - promise: this.promise, - } - - let formattedReceived = format(received) - let formattedArgument = format(argument) - - const pass = formattedReceived === formattedArgument - - const message = pass - ? () => { - return ( - this.utils.matcherHint('toMatchCss', undefined, undefined, options) + - '\n\n' + - `Expected: not ${this.utils.printExpected(formattedReceived)}\n` + - `Received: ${this.utils.printReceived(formattedArgument)}` - ) - } - : () => { - const actual = formattedReceived - const expected = formattedArgument - - const diffString = diff(expected, actual, { - expand: this.expand, - }) - - return ( - this.utils.matcherHint('toMatchCss', undefined, undefined, options) + - '\n\n' + - (diffString && diffString.includes('- Expect') - ? `Difference:\n\n${diffString}` - : `Expected: ${this.utils.printExpected(expected)}\n` + - `Received: ${this.utils.printReceived(actual)}`) - ) - } - - return { actual: received, message, pass } - }, -}) diff --git a/oxide/crates/core/src/parser.rs b/oxide/crates/core/src/parser.rs index 4bba1f947..8f9a14f73 100644 --- a/oxide/crates/core/src/parser.rs +++ b/oxide/crates/core/src/parser.rs @@ -43,14 +43,14 @@ impl<'a> Extractor<'a> { #[cfg(test)] pub fn unique_ord(input: &'a [u8], opts: ExtractorOptions) -> Vec<&'a [u8]> { - // This is an inefficient way to get an ordered, unique - // list as a Vec but it is only meant for testing. - let mut candidates = Self::all(input, opts); - let mut unique_list = FxHashSet::default(); - unique_list.reserve(candidates.len()); - candidates.retain(|c| unique_list.insert(*c)); + // This is an inefficient way to get an ordered, unique + // list as a Vec but it is only meant for testing. + let mut candidates = Self::all(input, opts); + let mut unique_list = FxHashSet::default(); + unique_list.reserve(candidates.len()); + candidates.retain(|c| unique_list.insert(*c)); - candidates + candidates } } diff --git a/src/corePlugins.js b/src/corePlugins.js index f92767e24..3f8931cdc 100644 --- a/src/corePlugins.js +++ b/src/corePlugins.js @@ -22,6 +22,7 @@ import { formatBoxShadowValue, parseBoxShadowValue } from './util/parseBoxShadow import { removeAlphaVariables } from './util/removeAlphaVariables' import { flagEnabled } from './featureFlags' import { normalize } from './util/dataTypes' +import { env } from './lib/sharedState' export let variantPlugins = { pseudoElementVariants: ({ addVariant }) => { @@ -546,16 +547,36 @@ export let corePlugins = { }, })) - addComponents([ - { - '.container': Object.assign( - { width: '100%' }, - theme('container.center', false) ? { marginRight: 'auto', marginLeft: 'auto' } : {}, - generatePaddingFor(0) - ), - }, - ...atRules, - ]) + if (env.OXIDE) { + addComponents([ + { + '.container': Object.assign( + { width: '100%' }, + theme('container.center', false) ? { marginRight: 'auto', marginLeft: 'auto' } : {}, + generatePaddingFor(0), + ...Array.from( + new Set(minWidths.slice().sort((a, z) => parseInt(a) - parseInt(z))) + ).map((minWidth) => ({ + [`@media (min-width: ${minWidth})`]: { + 'max-width': minWidth, + ...generatePaddingFor(minWidth), + }, + })) + ), + }, + ]) + } else { + addComponents([ + { + '.container': Object.assign( + { width: '100%' }, + theme('container.center', false) ? { marginRight: 'auto', marginLeft: 'auto' } : {}, + generatePaddingFor(0) + ), + }, + ...atRules, + ]) + } } })(), diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index c33276464..5fbe4308b 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -13,6 +13,7 @@ import { isValidVariantFormatString, parseVariant } from './setupContextUtils' import isValidArbitraryValue from '../util/isSyntacticallyValidPropertyValue' import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js' import { flagEnabled } from '../featureFlags' +import { resolveMatches as oxideResolveMatches } from '../oxide/core/resolve-matches' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value @@ -538,7 +539,7 @@ function* recordCandidates(matches, classCandidate) { } } -function* resolveMatches(candidate, context, original = candidate) { +export function* resolveMatches(candidate, context, original = candidate) { let separator = context.tailwindConfig.separator let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse() let important = false @@ -840,14 +841,34 @@ function getImportantStrategy(important) { return } - rule.selectors = rule.selectors.map((selector) => { - return `${important} ${selector}` - }) + if (sharedState.env.OXIDE) { + let isLeafRule = true + rule.walkRules(() => { + isLeafRule = false + return false + }) + + if (isLeafRule) { + let current = rule.clone() + let wrapper = postcss.rule({ + selector: `${important} &`, + nodes: current.nodes, + }) + current.nodes = [wrapper] + console.log(current.toString()) + rule.replaceWith(current) + return false + } + } else { + rule.selectors = rule.selectors.map((selector) => { + return `${important} ${selector}` + }) + } } } } -function generateRules(candidates, context) { +export function generateRules(candidates, context) { let allRules = [] let strategy = getImportantStrategy(context.tailwindConfig.important) @@ -861,7 +882,11 @@ function generateRules(candidates, context) { continue } - let matches = Array.from(resolveMatches(candidate, context)) + let matches = Array.from( + sharedState.env.OXIDE + ? oxideResolveMatches(candidate, context) + : resolveMatches(candidate, context) + ) if (matches.length === 0) { context.notClassCache.add(candidate) @@ -873,7 +898,7 @@ function generateRules(candidates, context) { let rules = context.candidateRuleCache.get(candidate) ?? new Set() context.candidateRuleCache.set(candidate, rules) - for (const match of matches) { + for (let match of matches) { let [{ sort, options }, rule] = match if (options.respectImportant && strategy) { @@ -895,5 +920,3 @@ function generateRules(candidates, context) { function isArbitraryValue(input) { return input.startsWith('[') && input.endsWith(']') } - -export { resolveMatches, generateRules } diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 2bb9350d7..6f36dae79 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -208,6 +208,10 @@ export function parseVariant(variant) { let fns = parseVariantFormatString(variant) .map((str) => { + if (env.OXIDE) { + return ({ format }) => format(str) + } + if (!str.startsWith('@')) { return ({ format }) => format(str) } @@ -913,6 +917,7 @@ function registerPlugins(plugins, context) { prefix(context, 'peer'), ] context.getClassOrder = function getClassOrder(classes) { + console.log(classes) // Non-util classes won't be generated, so we default them to null let sortedClassNames = new Map(classes.map((className) => [className, null])) diff --git a/src/oxide/cli/build/deps.ts b/src/oxide/cli/build/deps.ts index 2b4355b17..4af2223d2 100644 --- a/src/oxide/cli/build/deps.ts +++ b/src/oxide/cli/build/deps.ts @@ -48,6 +48,7 @@ export async function lightningcss(shouldMinify: boolean, result: Result) { targets: css.browserslistToTargets(browserslist(packageJson.browserslist)), drafts: { nesting: true, + customMedia: true, }, }) diff --git a/src/oxide/core/expand-apply-at-rules.ts b/src/oxide/core/expand-apply-at-rules.ts new file mode 100644 index 000000000..b5d785436 --- /dev/null +++ b/src/oxide/core/expand-apply-at-rules.ts @@ -0,0 +1,126 @@ +import postcss, { Rule } from 'postcss' +import type { Node, Root, Container } from 'postcss' +import parser from 'postcss-selector-parser' + +import { resolveMatches } from './resolve-matches' +import { Offsets } from '../../lib/offsets' + +interface TailwindContext { + offsets: Offsets +} + +let processor = parser() + +// Remove escapes and leading `.`: `.hover\:font-bold` -> `hover:font-bold` +function toCandidate(selector: string) { + return processor.astSync(selector).nodes[0].nodes[0].value +} + +// Make sure the selector is strictly a single class +function isApplyable(node: Node) { + if (!(node instanceof Rule)) return + + let ast = processor.astSync(node.selector) + + return ( + ast.nodes.length === 1 && + ast.nodes[0].nodes.length === 1 && + ast.nodes[0].nodes[0].type === 'class' + ) +} + +// Extract applyable classes into a map +function collectUserCSSApplyables( + root: Container, + context: TailwindContext, + applyables = new Map() +) { + root.each((node) => { + if (node.type === 'rule' && isApplyable(node)) { + let candidate = toCandidate(node.selector) + let rules = applyables.get(candidate) ?? [] + applyables.set(candidate, [...rules, [context.offsets.create('user'), node.clone()]]) + } + }) + + return applyables +} + +interface RuleOffset { + layer: 'base' | 'defaults' | 'components' | 'utilities' | 'variants' | 'user' + parentLayer: 'base' | 'defaults' | 'components' | 'utilities' | 'variants' | 'user' + arbitrary: bigint + variants: bigint + parallelIndex: bigint + index: bigint + options: { + id: number + sort?: Function + value: string | null + modifier: string | null + }[] +} + +export function expandApplyAtRules(context: TailwindContext) { + function* resolvePluginMatches( + rule: Container, + apply: string, + cache: Map + ) { + if (!cache.has(apply)) { + for (let [info, rule] of resolveMatches(apply, context)) { + if (!isApplyable(rule)) continue + cache.set(apply, [...(cache.get(apply) ?? []), [info.sort, rule]]) + } + } + + if (!cache.has(apply)) { + throw rule.error( + `The \`${apply}\` class does not exist. If \`${apply}\` is a custom class, make sure it is defined within a \`@layer\` directive.` + ) + } + + yield* cache.get(apply) ?? [] + } + + return (root: Root) => { + // Collect apply-ables + let applyables = collectUserCSSApplyables(root, context) + + // Already resolved cache + let resolvedMatchesCache = new Map() + + // Replace `@apply` with nodes from matching applyable + root.walkAtRules('apply', function processApplyAtRules(rule) { + let applies = postcss.list.space(rule.params) + let importantIdx = applies.indexOf('!important') + let important = importantIdx !== -1 + if (important) { + applies.splice(importantIdx, 1) + } + + let resolvedRules = context.offsets.sort( + applies.flatMap((apply) => [ + ...(applyables.get(apply) ?? []), + ...resolvePluginMatches(rule, apply, resolvedMatchesCache), + ]) + ) as [RuleOffset, Rule][] + + for (let [, applyRule] of resolvedRules) { + console.log(applyRule.toString()) + applyRule = applyRule.clone() + if (important) { + applyRule.walkDecls((decl) => { + decl.important = important + }) + } + rule.before(applyRule.nodes) + } + + rule.remove() + + // Recursively apply @apply + root.walkAtRules('apply', processApplyAtRules) + }) + } +} diff --git a/src/oxide/core/resolve-matches.ts b/src/oxide/core/resolve-matches.ts new file mode 100644 index 000000000..590715ebc --- /dev/null +++ b/src/oxide/core/resolve-matches.ts @@ -0,0 +1,829 @@ +import postcss from 'postcss' +import selectorParser from 'postcss-selector-parser' +import type { Node, Container, AtRule } from 'postcss' +import parseObjectStyles from '../../lib/../util/parseObjectStyles' +import isPlainObject from '../../lib/../util/isPlainObject' +import prefixSelector from '../../lib/../util/prefixSelector' +import { + updateAllClasses, + filterSelectorsForClass, + getMatchingTypes, +} from '../../lib/../util/pluginUtils' +import log from '../../lib/../util/log' +import * as sharedState from '../../lib/./sharedState' +import { asClass } from '../../lib/../util/nameClass' +import { normalize } from '../../lib/../util/dataTypes' +import { isValidVariantFormatString, parseVariant } from '../../lib/./setupContextUtils' +import isValidArbitraryValue from '../../lib/../util/isSyntacticallyValidPropertyValue' +import { splitAtTopLevelOnly } from '../../lib/../util/splitAtTopLevelOnly.js' +import { flagEnabled } from '../../lib/../featureFlags' +import { formatVariantSelector } from '../../util/formatVariantSelector' + +type Layer = 'base' | 'defaults' | 'components' | 'utilities' | 'variants' | 'user' + +interface RuleOffset { + layer: Layer + parentLayer: Layer + arbitrary: bigint + variants: bigint + parallelIndex: bigint + index: bigint + options: { + id: number + sort?: Function + value: string | null + modifier: string | null + }[] +} + +type Match = [ + { + sort: RuleOffset + layer: Layer + options: { + preserveSource: boolean + respectPrefix: boolean + respectImportant: boolean + types: { type: string; preferOnConflict?: boolean }[] + // collectedFormats?: string[] + } + important: boolean + }, + Node +] + +type Context = any + +// Generate match permutations for a class candidate, like: +// ['ring-offset-blue', '100'] +// ['ring-offset', 'blue-100'] +// ['ring', 'offset-blue-100'] +// Example with dynamic classes: +// ['grid-cols', '[[linename],1fr,auto]'] +// ['grid', 'cols-[[linename],1fr,auto]'] +function* candidatePermutations(candidate: string) { + let lastIndex = Infinity + + while (lastIndex >= 0) { + let dashIdx: number + let wasSlash = false + + if (lastIndex === Infinity && candidate.endsWith(']')) { + let bracketIdx = candidate.indexOf('[') + + // If character before `[` isn't a dash or a slash, this isn't a dynamic class + // eg. string[] + if (candidate[bracketIdx - 1] === '-') { + dashIdx = bracketIdx - 1 + } else if (candidate[bracketIdx - 1] === '/') { + dashIdx = bracketIdx - 1 + wasSlash = true + } else { + dashIdx = -1 + } + } else if (lastIndex === Infinity && candidate.includes('/')) { + dashIdx = candidate.lastIndexOf('/') + wasSlash = true + } else { + dashIdx = candidate.lastIndexOf('-', lastIndex) + } + + if (dashIdx < 0) { + break + } + + let prefix = candidate.slice(0, dashIdx) + let modifier = candidate.slice(wasSlash ? dashIdx : dashIdx + 1) + + lastIndex = dashIdx - 1 + + // TODO: This feels a bit hacky + if (prefix === '' || modifier === '/') { + continue + } + + yield [prefix, modifier] + } +} + +function applyPrefix(matches: Match[], context: Context) { + if (matches.length === 0 || context.tailwindConfig.prefix === '') { + return matches + } + + for (let match of matches) { + let [meta] = match + if (meta.options.respectPrefix) { + let container = postcss.root({ nodes: [match[1].clone()] }) + let classCandidate = match[1].raws.tailwind.classCandidate + + container.walkRules((r) => { + // If this is a negative utility with a dash *before* the prefix we + // have to ensure that the generated selector matches the candidate + + // Not doing this will cause `-tw-top-1` to generate the class `.tw--top-1` + // The disconnect between candidate <-> class can cause @apply to hard crash. + let shouldPrependNegative = classCandidate.startsWith('-') + + r.selector = prefixSelector( + context.tailwindConfig.prefix, + r.selector, + shouldPrependNegative + ) + }) + + match[1] = container.nodes[0] + } + } + + return matches +} + +function applyImportant(matches: Match[]) { + if (matches.length === 0) { + return matches + } + + let result: Match[] = [] + + for (let [meta, rule] of matches) { + let container = postcss.root({ nodes: [rule.clone()] }) + + container.walkDecls((d) => { + d.important = true + }) + + // BUG WORKAROUND: Wrapping the contents of the atRule in an additional rule using + // the simple & selector. This allows lightning to properly parse the contents of + // a @media rule where a declaration has the !important modifier. + // + // Expected, but doesn't work: + // .foo { + // @media (min-width: 400px) { + // color: red !important; + // } + // } + // + // Workaround produces this, which works: + // .foo { + // @media (min-width: 400px) { + // & { + // color: red !important; + // } + // } + // } + container.walkAtRules((rule) => { + rule.nodes = [postcss.rule({ selector: '&', nodes: rule.nodes })] + }) + + // TODO: What is this `important` doing? + result.push([{ ...meta, important: true }, container.nodes[0]]) + } + + return result +} + +// Takes a list of rule tuples and applies a variant like `hover`, sm`, +// whatever to it. We used to do some extra caching here to avoid generating +// a variant of the same rule more than once, but this was never hit because +// we cache at the entire selector level further up the tree. +// +// Technically you can get a cache hit if you have `hover:focus:text-center` +// and `focus:hover:text-center` in the same project, but it doesn't feel +// worth the complexity for that case. + +function applyVariant(variant: string, children: Match[], context: any) { + if (children.length === 0) { + return children + } + + let args: { + modifier?: string | null + value?: string | null | typeof sharedState.NONE + } = { modifier: null, value: sharedState.NONE } + + // Retrieve "modifier" + { + let match = /(.*)\/(.*)$/g.exec(variant) + if (match) { + variant = match[1] + args.modifier = match[2] + + if (!flagEnabled(context.tailwindConfig, 'generalizedModifiers')) { + return [] + } + } + } + + // Retrieve "arbitrary value" + if (variant.endsWith(']') && !variant.startsWith('[')) { + // We either have: + // @[200px] + // group-[:hover] + // + // But we don't want: + // @-[200px] (`-` is incorrect) + // group[:hover] (`-` is missing) + let match = /(.)(-?)\[(.*)\]/g.exec(variant) + if (match) { + let [, char, seperator, value] = match + // @-[200px] case + if (char === '@' && seperator === '-') return [] + // group[:hover] case + if (char !== '@' && seperator === '') return [] + + variant = variant.replace(`${seperator}[${value}]`, '') + args.value = value + } + } + + // Register arbitrary variants + if (isArbitraryValue(variant) && !context.variantMap.has(variant)) { + let selector = normalize(variant.slice(1, -1)) + + if (!isValidVariantFormatString(selector)) { + return [] + } + + let fn = parseVariant(selector) + + let sort = context.offsets.recordVariant(variant) + + context.variantMap.set(variant, [[sort, fn]]) + } + + if (!context.variantMap.has(variant)) { + return [] + } + + let variantFunctionTuples = context.variantMap.get(variant).slice() + let result: [any, Node][] = [] + + for (let [meta, rule] of children) { + // Don't generate variants for user css + if (meta.layer === 'user') { + continue + } + + let container = postcss.root({ nodes: [rule.clone()] }) + let allFormats: string[] = [] + + for (let [variantSort, variantFunction, containerFromArray] of variantFunctionTuples) { + let clone: Container = (containerFromArray ?? container).clone() + + let collectedFormats: string[] = [] + + let ruleWithVariant = variantFunction({ + // Public API + container: clone, + + // Private API for now + wrap(wrapper: AtRule) { + collectedFormats.push(`@${wrapper.name} ${wrapper.params}`) + }, + format(selectorFormat: string) { + collectedFormats.push(selectorFormat) + }, + args, + }) + + // It can happen that a list of format strings is returned from within the function. In that + // case, we have to process them as well. We can use the existing `variantSort`. + if (Array.isArray(ruleWithVariant)) { + for (let [idx, variantFunction] of ruleWithVariant.entries()) { + // This is a little bit scary since we are pushing to an array of items that we are + // currently looping over. However, you can also think of it like a processing queue + // where you keep handling jobs until everything is done and each job can queue more + // jobs if needed. + variantFunctionTuples.push([ + context.offsets.applyParallelOffset(variantSort, idx), + variantFunction, + + // If the clone has been modified we have to pass that back + // though so each rule can use the modified container + clone.clone(), + ]) + } + continue + } + + if (typeof ruleWithVariant === 'string') { + collectedFormats.push(ruleWithVariant) + } + + if (ruleWithVariant === null) { + continue + } + + // This tracks the originating layer for the variant + // For example: + // .sm:underline {} is a variant of something in the utilities layer + // .sm:container {} is a variant of the container component + clone.nodes[0].raws.tailwind = { ...clone.nodes[0].raws.tailwind, parentLayer: meta.layer } + + let localClone = clone.clone() + allFormats.push(...collectedFormats) + if (collectedFormats.length <= 0) { + collectedFormats.push('&') + } + + for (let format of collectedFormats) { + localClone.walkRules((rule) => { + let children = rule.clone().nodes + + let wrapper = (function () { + /* + * Wrap in an atRule + */ + if (format.startsWith('@')) { + let matches = /@(.*?)( .+|[({].*)/g.exec(format) + if (matches) { + let [, name, params] = matches + return postcss.atRule({ + name: name.trim(), + params: params.trim(), + // BUG WORKAROUND: Wrapping the contents of the atRule in an additional rule using + // the simple & selector. This allows lightning to properly parse the contents of + // a @media rule where a declaration has the !important modifier. + // + // Expected, but doesn't work: + // .foo { + // @media (min-width: 400px) { + // color: red !important; + // } + // } + // + // Workaround produces this, which works: + // .foo { + // @media (min-width: 400px) { + // & { + // color: red !important; + // } + // } + // } + nodes: [postcss.rule({ selector: '&', nodes: children })], + }) + } + } + + /* + * Wrap in a rule with the new selector + */ + // TODO: Handle the `:merge` + if (format.includes(':merge')) { + let [, target, rest] = /\:merge\((.*)\)([^ ]*)/g.exec(format)! + let found = false + let childrenContainer = postcss.root({ nodes: children }) + childrenContainer.walkRules((rule) => { + if (rule.selector.includes(`:merge(${target})`)) { + let [, target, rest] = /\:merge\((.*)\)([^ ]*)/g.exec(rule.selector)! + rule.selector = format.replace(`:merge(${target})`, `:merge(${target})${rest}`) + // rule.selector = rule.selector.replace( + // `:merge(${target})`, + // `:merge(${target})${rest}` + // ) + found = true + } + }) + // return postcss.root({ nodes: children }) + return postcss.rule({ + selector: found ? '&' : format, + nodes: childrenContainer.nodes, + }) + } + + return postcss.rule({ selector: format, nodes: children }) + })() + + rule.replaceWith(rule.clone({ nodes: [wrapper] })) + return false + }) + } + + // console.log(localClone.toString()) + + result.push([ + { + ...meta, + sort: context.offsets.applyVariantOffset( + meta.sort, + variantSort, + Object.assign(args, context.variantOptions.get(variant)) + ), + // collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats), + isArbitraryVariant: isArbitraryValue(variant), + }, + localClone.nodes[0], + ]) + } + } + + return result +} + +function parseRules(rule: Node, cache, options = {}) { + // PostCSS node + if (!isPlainObject(rule) && !Array.isArray(rule)) { + return [[rule], options] + } + + // Tuple + if (Array.isArray(rule)) { + return parseRules(rule[0], cache, rule[1]) + } + + // Simple object + if (!cache.has(rule)) { + cache.set(rule, parseObjectStyles(rule)) + } + + return [cache.get(rule), options] +} + +const IS_VALID_PROPERTY_NAME = /^[a-z_-]/ + +function isValidPropName(name) { + return IS_VALID_PROPERTY_NAME.test(name) +} + +/** + * @param {string} declaration + * @returns {boolean} + */ +function looksLikeUri(declaration) { + // Quick bailout for obvious non-urls + // This doesn't support schemes that don't use a leading // but that's unlikely to be a problem + if (!declaration.includes('://')) { + return false + } + + try { + const url = new URL(declaration) + return url.scheme !== '' && url.host !== '' + } catch (err) { + // Definitely not a valid url + return false + } +} + +function isParsableNode(node: Node) { + let isParsable = true + let container = postcss.root({ nodes: [node.clone()] }) + + container.walkDecls((decl) => { + if (!isParsableCssValue(decl.prop, decl.value)) { + isParsable = false + return false + } + }) + + return isParsable +} + +function isParsableCssValue(property: string, value: string) { + // We don't want to to treat [https://example.com] as a custom property + // Even though, according to the CSS grammar, it's a totally valid CSS declaration + // So we short-circuit here by checking if the custom property looks like a url + if (looksLikeUri(`${property}:${value}`)) { + return false + } + + try { + postcss.parse(`a{${property}:${value}}`).toResult() + return true + } catch (err) { + return false + } +} + +function extractArbitraryProperty(classCandidate: string, context: Context) { + let [, property, value] = classCandidate.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? [] + + if (value === undefined) { + return null + } + + if (!isValidPropName(property)) { + return null + } + + if (!isValidArbitraryValue(value)) { + return null + } + + let normalized = normalize(value) + + if (!isParsableCssValue(property, normalized)) { + return null + } + + let sort = context.offsets.arbitraryProperty() + + return [ + [ + { sort, layer: 'utilities' }, + () => ({ + [asClass(classCandidate)]: { + [property]: normalized, + }, + }), + ], + ] +} + +function* resolveMatchedPlugins(classCandidate: string, context: Context) { + if (context.candidateRuleMap.has(classCandidate)) { + yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT'] + } + + yield* (function* (arbitraryPropertyRule) { + if (arbitraryPropertyRule !== null) { + yield [arbitraryPropertyRule, 'DEFAULT'] + } + })(extractArbitraryProperty(classCandidate, context)) + + let candidatePrefix = classCandidate + let negative = false + + const twConfigPrefix = context.tailwindConfig.prefix + + const twConfigPrefixLen = twConfigPrefix.length + + const hasMatchingPrefix = + candidatePrefix.startsWith(twConfigPrefix) || candidatePrefix.startsWith(`-${twConfigPrefix}`) + + if (candidatePrefix[twConfigPrefixLen] === '-' && hasMatchingPrefix) { + negative = true + candidatePrefix = twConfigPrefix + candidatePrefix.slice(twConfigPrefixLen + 1) + } + + if (negative && context.candidateRuleMap.has(candidatePrefix)) { + yield [context.candidateRuleMap.get(candidatePrefix), '-DEFAULT'] + } + + for (let [prefix, modifier] of candidatePermutations(candidatePrefix)) { + if (context.candidateRuleMap.has(prefix)) { + yield [context.candidateRuleMap.get(prefix), negative ? `-${modifier}` : modifier] + } + } +} + +function splitWithSeparator(input: string, separator: string): string[] { + if (input === sharedState.NOT_ON_DEMAND) { + return [sharedState.NOT_ON_DEMAND as string] + } + + return splitAtTopLevelOnly(input, separator) +} + +function* recordCandidates(matches: Match[], classCandidate: string) { + for (let match of matches) { + match[1].raws.tailwind = { + ...match[1].raws.tailwind, + classCandidate, + preserveSource: match[0].options?.preserveSource ?? false, + } + + yield match + } +} + +export function* resolveMatches(candidate: string, context: Context, original = candidate) { + let separator = context.tailwindConfig.separator + let variants = splitWithSeparator(candidate, separator) + let classCandidate = variants.pop()! + let important = false + + if (classCandidate.startsWith('!')) { + important = true + classCandidate = classCandidate.slice(1) + } + + if (flagEnabled(context.tailwindConfig, 'variantGrouping')) { + if (classCandidate.startsWith('(') && classCandidate.endsWith(')')) { + let base = variants.slice().reverse().join(separator) + for (let part of splitAtTopLevelOnly(classCandidate.slice(1, -1), ',')) { + yield* resolveMatches(base + separator + part, context, original) + } + } + } + + // TODO: Reintroduce this in ways that doesn't break on false positives + // function sortAgainst(toSort, against) { + // return toSort.slice().sort((a, z) => { + // return bigSign(against.get(a)[0] - against.get(z)[0]) + // }) + // } + // let sorted = sortAgainst(variants, context.variantMap) + // if (sorted.toString() !== variants.toString()) { + // let corrected = sorted.reverse().concat(classCandidate).join(':') + // throw new Error(`Class ${candidate} should be written as ${corrected}`) + // } + + for (let matchedPlugins of resolveMatchedPlugins(classCandidate, context)) { + let matches: Match[][] = [] + let typesByMatches = new Map() + + let [plugins, modifier] = matchedPlugins + let isOnlyPlugin = plugins.length === 1 + + for (let [sort, plugin] of plugins) { + let matchesPerPlugin: Match[] = [] + + if (typeof plugin === 'function') { + for (let ruleSet of [].concat(plugin(modifier, { isOnlyPlugin }))) { + let [rules, options] = parseRules(ruleSet, context.postCssNodeCache) + for (let rule of rules) { + matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule]) + } + } + } + // Only process static plugins on exact matches + else if (modifier === 'DEFAULT' || modifier === '-DEFAULT') { + let ruleSet = plugin + let [rules, options] = parseRules(ruleSet, context.postCssNodeCache) + for (let rule of rules) { + matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule]) + } + } + + if (matchesPerPlugin.length > 0) { + let matchingTypes = Array.from( + getMatchingTypes( + sort.options?.types ?? [], + modifier, + sort.options ?? {}, + context.tailwindConfig + ) + ).map(([_, type]) => type) + + if (matchingTypes.length > 0) { + typesByMatches.set(matchesPerPlugin, matchingTypes) + } + + matches.push(matchesPerPlugin) + } + } + + if (isArbitraryValue(modifier)) { + if (matches.length > 1) { + // Partition plugins in 2 categories so that we can start searching in the plugins that + // don't have `any` as a type first. + let [withAny, withoutAny] = matches.reduce( + (group, plugin) => { + let hasAnyType = plugin.some(([{ options }]) => + options.types.some(({ type }) => type === 'any') + ) + + if (hasAnyType) { + group[0].push(plugin) + } else { + group[1].push(plugin) + } + return group + }, + [[] as Match[][], [] as Match[][]] + ) + + function findFallback(matches: Match[]) { + // If only a single plugin matches, let's take that one + if (matches.length === 1) { + return matches[0] + } + + // Otherwise, find the plugin that creates a valid rule given the arbitrary value, and + // also has the correct type which preferOnConflicts the plugin in case of clashes. + return matches.find((rules) => { + let matchingTypes = typesByMatches.get(rules) + return rules.some(([{ options }, rule]) => { + if (!isParsableNode(rule)) { + return false + } + + return options.types.some( + ({ type, preferOnConflict }) => matchingTypes.includes(type) && preferOnConflict + ) + }) + }) + } + + // Try to find a fallback plugin, because we already know that multiple plugins matched for + // the given arbitrary value. + let fallback = findFallback(withoutAny) ?? findFallback(withAny) + if (fallback) { + matches = [fallback] + } + + // We couldn't find a fallback plugin which means that there are now multiple plugins that + // generated css for the current candidate. This means that the result is ambiguous and this + // should not happen. We won't generate anything right now, so let's report this to the user + // by logging some options about what they can do. + else { + let typesPerPlugin = matches.map( + (match) => new Set([...(typesByMatches.get(match) ?? [])]) + ) + + // Remove duplicates, so that we can detect proper unique types for each plugin. + for (let pluginTypes of typesPerPlugin) { + for (let type of pluginTypes) { + let removeFromOwnGroup = false + + for (let otherGroup of typesPerPlugin) { + if (pluginTypes === otherGroup) continue + + if (otherGroup.has(type)) { + otherGroup.delete(type) + removeFromOwnGroup = true + } + } + + if (removeFromOwnGroup) pluginTypes.delete(type) + } + } + + let messages: string[] = [] + + for (let [idx, group] of typesPerPlugin.entries()) { + for (let type of group) { + let rules = matches[idx] + .map(([, rule]) => rule) + .flat() + .map((rule) => + rule + .toString() + .split('\n') + .slice(1, -1) // Remove selector and closing '}' + .map((line) => line.trim()) + .map((x) => ` ${x}`) // Re-indent + .join('\n') + ) + .join('\n\n') + + messages.push( + ` Use \`${candidate.replace('[', `[${type}:`)}\` for \`${rules.trim()}\`` + ) + break + } + } + + log.warn([ + `The class \`${candidate}\` is ambiguous and matches multiple utilities.`, + ...messages, + `If this is content and not a class, replace it with \`${candidate + .replace('[', '[') + .replace(']', ']')}\` to silence this warning.`, + ]) + continue + } + } + + matches = matches.map((list) => list.filter((match) => isParsableNode(match[1]))) + } + + let flattenedMatches = matches.flat() + flattenedMatches = Array.from(recordCandidates(flattenedMatches, classCandidate)) + flattenedMatches = applyPrefix(flattenedMatches, context) + flattenedMatches = applyNewSelector(flattenedMatches, { from: classCandidate, to: candidate }) + + if (important) { + flattenedMatches = applyImportant(flattenedMatches) + } + + for (let variant of variants) { + flattenedMatches = applyVariant(variant, flattenedMatches, context) + } + + yield* flattenedMatches + } +} + +function applyNewSelector(matches: Match[], info: { from: string; to: string }) { + if (typeof info.from !== 'string' && typeof info.to !== 'string') { + return matches + } + + if (info.from === info.to) { + return matches + } + + for (let match of matches) { + let container = postcss.root({ nodes: [match[1].clone()] }) + container.walkRules((node) => { + let ast = selectorParser().astSync(node.selector) + ast.walkClasses((node) => { + if (node.value === info.from) { + node.value = info.to + } + }) + node.selector = ast.toString() + return false + }) + match[1] = container.nodes[0] + } + + return matches +} + +function isArbitraryValue(input: string) { + return input.startsWith('[') && input.endsWith(']') +} diff --git a/src/plugin.js b/src/plugin.js index 7dc41c317..d5a5ee747 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -34,6 +34,47 @@ module.exports = function tailwindcss(configOrPath) { processTailwindFeatures(context)(root, result) }, + // false && + env.OXIDE && + function (_root, result) { + let postcss = require('postcss') + let lightningcss = require('lightningcss') + let browserslist = require('browserslist') + + try { + let transformed = lightningcss.transform({ + filename: result.opts.from, + code: Buffer.from(result.root.toString()), + minify: false, + sourceMap: !!result.map, + inputSourceMap: result.map ? result.map.toString() : undefined, + targets: + typeof process !== 'undefined' && process.env.JEST_WORKER_ID + ? { chrome: 106 << 16 } + : lightningcss.browserslistToTargets( + browserslist(require('../package.json').browserslist) + ), + + drafts: { + nesting: true, + customMedia: true, + }, + }) + + result.map + ? Object.assign(result.map, { + toString() { + return transformed.map.toString() + }, + }) + : result.map + + result.root = postcss.parse(transformed.code.toString('utf8')) + } catch (err) { + console.error('Unable to use Lightning CSS. Using raw version instead.') + console.error(err) + } + }, env.DEBUG && function (root) { console.timeEnd('JIT TOTAL') diff --git a/src/processTailwindFeatures.js b/src/processTailwindFeatures.js index 952e17a13..dc9bfddde 100644 --- a/src/processTailwindFeatures.js +++ b/src/processTailwindFeatures.js @@ -1,3 +1,4 @@ +import selectorParser from 'postcss-selector-parser' import normalizeTailwindDirectives from './lib/normalizeTailwindDirectives' import expandTailwindAtRules from './lib/expandTailwindAtRules' import expandApplyAtRules from './lib/expandApplyAtRules' @@ -10,12 +11,16 @@ import partitionApplyAtRules from './lib/partitionApplyAtRules' import detectNesting from './lib/detectNesting' import { createContext } from './lib/setupContextUtils' import { issueFlagNotices } from './featureFlags' +import { env } from './lib/sharedState' +import { expandApplyAtRules as oxideExpandApplyAtRules } from './oxide/core/expand-apply-at-rules' export default function processTailwindFeatures(setupContext) { return function (root, result) { let { tailwindDirectives, applyDirectives } = normalizeTailwindDirectives(root) - detectNesting()(root, result) + if (!env.OXIDE) { + detectNesting()(root, result) + } // Partition apply rules that are found in the css // itself. @@ -48,11 +53,30 @@ export default function processTailwindFeatures(setupContext) { // Partition apply rules that are generated by // addComponents, addUtilities and so on. partitionApplyAtRules()(root, result) - expandApplyAtRules(context)(root, result) + if (env.OXIDE) { + oxideExpandApplyAtRules(context)(root, result) + } else { + expandApplyAtRules(context)(root, result) + } evaluateTailwindFunctions(context)(root, result) substituteScreenAtRules(context)(root, result) resolveDefaultsAtRules(context)(root, result) + collapseAdjacentRules(context)(root, result) collapseDuplicateDeclarations(context)(root, result) + + // Drop :merge() + root.walkRules((rule) => { + if (rule.selector.includes(':merge')) { + let selectorFunctions = new Set([':merge']) + let ast = selectorParser().astSync(rule.selector) + ast.walkPseudos((node) => { + if (selectorFunctions.has(node.value)) { + node.replaceWith(node.nodes) + } + }) + rule.selector = ast.toString() + } + }) } } diff --git a/src/util/parseObjectStyles.js b/src/util/parseObjectStyles.js index cb54787de..a692f3dfd 100644 --- a/src/util/parseObjectStyles.js +++ b/src/util/parseObjectStyles.js @@ -2,17 +2,17 @@ import postcss from 'postcss' import postcssNested from 'postcss-nested' import postcssJs from 'postcss-js' +import { env } from '../lib/sharedState' + export default function parseObjectStyles(styles) { if (!Array.isArray(styles)) { return parseObjectStyles([styles]) } return styles.flatMap((style) => { - return postcss([ - postcssNested({ - bubble: ['screen'], - }), - ]).process(style, { + return postcss( + [env.OXIDE ? null : postcssNested({ bubble: ['screen'] })].filter(Boolean) + ).process(style, { parser: postcssJs, }).root.nodes }) diff --git a/tests/apply.test.js b/tests/apply.test.js index 6f7214d99..0ee764b8e 100644 --- a/tests/apply.test.js +++ b/tests/apply.test.js @@ -2,921 +2,912 @@ import fs from 'fs' import path from 'path' import { run, html, css, defaults } from './util/run' +import { env } from '../src/lib/sharedState' -test('@apply', () => { - let config = { - darkMode: 'class', - content: [path.resolve(__dirname, './apply.test.html')], - } +let group = env.OXIDE ? describe.skip : describe - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .basic-example { - @apply rounded-md bg-blue-500 px-4 py-2; - } - .class-order { - @apply p-8 px-3 py-7 pt-4 pr-1; - } - .with-additional-properties { - font-weight: 500; - @apply text-right; - } - .variants { - @apply font-semibold hover:font-bold focus:font-medium lg:font-light xl:focus:font-black; - } - .only-variants { - @apply hover:font-bold focus:font-medium lg:font-light xl:focus:font-black; - } - .apply-group-variant { - @apply group-hover:text-center lg:group-hover:text-left; - } - .apply-dark-variant { - @apply dark:text-center dark:hover:text-right lg:dark:text-left; - } - .apply-custom-utility { - @apply custom-util hover:custom-util lg:custom-util xl:focus:custom-util; - } - .multiple, - .selectors { - @apply rounded-md bg-blue-500 px-4 py-2; - } - .multiple-variants, - .selectors-variants { - @apply hover:text-center active:text-right lg:focus:text-left; - } - .multiple-group, - .selectors-group { - @apply group-hover:text-center lg:group-hover:text-left; - } - .complex-utilities { - @apply ordinal tabular-nums shadow-lg hover:shadow-xl focus:diagonal-fractions; - } - .use-base-only-a { - @apply font-bold; - } - .use-base-only-b { - @apply use-base-only-a font-normal; - } - .use-dependant-only-a { - @apply font-bold; - } - .use-dependant-only-b { - @apply use-dependant-only-a font-normal; - } - .btn { - @apply rounded py-2 px-4 font-bold; - } - .btn-blue { - @apply btn bg-blue-500 text-white hover:bg-blue-700; - } - .recursive-apply-a { - @apply font-black sm:font-thin; - } - .recursive-apply-b { - @apply recursive-apply-a font-semibold md:font-extralight; - } - .recursive-apply-c { - @apply recursive-apply-b font-bold lg:font-light; - } - .use-with-other-properties-base { - color: green; - @apply font-bold; - } - .use-with-other-properties-component { - @apply use-with-other-properties-base; - } - .add-sibling-properties { - padding: 2rem; - @apply px-4 hover:px-2 lg:px-10 xl:focus:px-1; - padding-top: 3px; - @apply use-with-other-properties-base; - } - - h1 { - @apply text-2xl sm:text-3xl lg:text-2xl; - } - h2 { - @apply text-2xl; - @apply lg:text-2xl; - @apply sm:text-2xl; - } - - .important-modifier { - @apply !rounded-md px-4; - } - - .important-modifier-variant { - @apply px-4 hover:!rounded-md; - } +group('@apply', () => { + test('@apply', () => { + let config = { + darkMode: 'class', + content: [path.resolve(__dirname, './apply.test.html')], } - @layer utilities { - .custom-util { - custom: stuff; + let input = css` + @tailwind components; + @tailwind utilities; + + @layer components { + .basic-example { + @apply rounded-md bg-blue-500 px-4 py-2; + } + .class-order { + @apply p-8 px-3 py-7 pt-4 pr-1; + } + .with-additional-properties { + font-weight: 500; + @apply text-right; + } + .variants { + @apply font-semibold hover:font-bold focus:font-medium lg:font-light xl:focus:font-black; + } + .only-variants { + @apply hover:font-bold focus:font-medium lg:font-light xl:focus:font-black; + } + .apply-group-variant { + @apply group-hover:text-center lg:group-hover:text-left; + } + .apply-dark-variant { + @apply dark:text-center dark:hover:text-right lg:dark:text-left; + } + .apply-custom-utility { + @apply custom-util hover:custom-util lg:custom-util xl:focus:custom-util; + } + .multiple, + .selectors { + @apply rounded-md bg-blue-500 px-4 py-2; + } + .multiple-variants, + .selectors-variants { + @apply hover:text-center active:text-right lg:focus:text-left; + } + .multiple-group, + .selectors-group { + @apply group-hover:text-center lg:group-hover:text-left; + } + .complex-utilities { + @apply ordinal tabular-nums shadow-lg hover:shadow-xl focus:diagonal-fractions; + } + .use-base-only-a { + @apply font-bold; + } + .use-base-only-b { + @apply use-base-only-a font-normal; + } + .use-dependant-only-a { + @apply font-bold; + } + .use-dependant-only-b { + @apply use-dependant-only-a font-normal; + } + .btn { + @apply rounded py-2 px-4 font-bold; + } + .btn-blue { + @apply btn bg-blue-500 text-white hover:bg-blue-700; + } + .recursive-apply-a { + @apply font-black sm:font-thin; + } + .recursive-apply-b { + @apply recursive-apply-a font-semibold md:font-extralight; + } + .recursive-apply-c { + @apply recursive-apply-b font-bold lg:font-light; + } + .use-with-other-properties-base { + color: green; + @apply font-bold; + } + .use-with-other-properties-component { + @apply use-with-other-properties-base; + } + .add-sibling-properties { + padding: 2rem; + @apply px-4 hover:px-2 lg:px-10 xl:focus:px-1; + padding-top: 3px; + @apply use-with-other-properties-base; + } + + h1 { + @apply text-2xl sm:text-3xl lg:text-2xl; + } + h2 { + @apply text-2xl; + @apply lg:text-2xl; + @apply sm:text-2xl; + } + + .important-modifier { + @apply !rounded-md px-4; + } + + .important-modifier-variant { + @apply px-4 hover:!rounded-md; + } } - .foo { - @apply animate-spin; + @layer utilities { + .custom-util { + custom: stuff; + } + + .foo { + @apply animate-spin; + } + + .bar { + @apply animate-pulse !important; + } } + ` - .bar { - @apply animate-pulse !important; - } - } - ` + return run(input, config).then((result) => { + let expectedPath = path.resolve(__dirname, './apply.test.css') + let expected = fs.readFileSync(expectedPath, 'utf8') - return run(input, config).then((result) => { - let expectedPath = path.resolve(__dirname, './apply.test.css') - let expected = fs.readFileSync(expectedPath, 'utf8') - - expect(result.css).toMatchFormattedCss(expected) + expect(result.css).toMatchFormattedCss(expected) + }) }) -}) -test('@apply error with unknown utility', async () => { - let config = { - darkMode: 'class', - content: [path.resolve(__dirname, './apply.test.html')], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .foo { - @apply a-utility-that-does-not-exist; - } + test('@apply error with unknown utility', async () => { + let config = { + darkMode: 'class', + content: [path.resolve(__dirname, './apply.test.html')], } - ` - await expect(run(input, config)).rejects.toThrowError('class does not exist') -}) + let input = css` + @tailwind components; + @tailwind utilities; -test('@apply error with nested @screen', async () => { - let config = { - darkMode: 'class', - content: [path.resolve(__dirname, './apply.test.html')], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .foo { - @screen md { - @apply text-black; + @layer components { + .foo { + @apply a-utility-that-does-not-exist; } } + ` + + await expect(run(input, config)).rejects.toThrowError('class does not exist') + }) + + test('@apply error with nested @screen', async () => { + let config = { + darkMode: 'class', + content: [path.resolve(__dirname, './apply.test.html')], } - ` - await expect(run(input, config)).rejects.toThrowError( - '@apply is not supported within nested at-rules like @screen' - ) -}) + let input = css` + @tailwind components; + @tailwind utilities; -test('@apply error with nested @anyatrulehere', async () => { - let config = { - darkMode: 'class', - content: [path.resolve(__dirname, './apply.test.html')], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .foo { - @genie { - @apply text-black; + @layer components { + .foo { + @screen md { + @apply text-black; + } } } + ` + + await expect(run(input, config)).rejects.toThrowError( + '@apply is not supported within nested at-rules like @screen' + ) + }) + + test('@apply error with nested @anyatrulehere', async () => { + let config = { + darkMode: 'class', + content: [path.resolve(__dirname, './apply.test.html')], } - ` - await expect(run(input, config)).rejects.toThrowError( - '@apply is not supported within nested at-rules like @genie' - ) -}) + let input = css` + @tailwind components; + @tailwind utilities; -test('@apply error when using .group utility', async () => { - let config = { - darkMode: 'class', - content: [{ raw: '
' }], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .foo { - @apply group; + @layer components { + .foo { + @genie { + @apply text-black; + } + } } + ` + + await expect(run(input, config)).rejects.toThrowError( + '@apply is not supported within nested at-rules like @genie' + ) + }) + + test('@apply error when using .group utility', async () => { + let config = { + darkMode: 'class', + content: [{ raw: '
' }], } - ` - await expect(run(input, config)).rejects.toThrowError( - `@apply should not be used with the 'group' utility` - ) -}) + let input = css` + @tailwind components; + @tailwind utilities; -test('@apply error when using a prefixed .group utility', async () => { - let config = { - prefix: 'tw-', - darkMode: 'class', - content: [{ raw: html`
` }], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .foo { - @apply tw-group; + @layer components { + .foo { + @apply group; + } } + ` + + await expect(run(input, config)).rejects.toThrowError( + `@apply should not be used with the 'group' utility` + ) + }) + + test('@apply error when using a prefixed .group utility', async () => { + let config = { + prefix: 'tw-', + darkMode: 'class', + content: [{ raw: html`
` }], } - ` - await expect(run(input, config)).rejects.toThrowError( - `@apply should not be used with the 'tw-group' utility` - ) -}) + let input = css` + @tailwind components; + @tailwind utilities; -test('@apply error when using .peer utility', async () => { - let config = { - darkMode: 'class', - content: [{ raw: '
' }], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .foo { - @apply peer; + @layer components { + .foo { + @apply tw-group; + } } + ` + + await expect(run(input, config)).rejects.toThrowError( + `@apply should not be used with the 'tw-group' utility` + ) + }) + + test('@apply error when using .peer utility', async () => { + let config = { + darkMode: 'class', + content: [{ raw: '
' }], } - ` - await expect(run(input, config)).rejects.toThrowError( - `@apply should not be used with the 'peer' utility` - ) -}) + let input = css` + @tailwind components; + @tailwind utilities; -test('@apply error when using a prefixed .peer utility', async () => { - let config = { - prefix: 'tw-', - darkMode: 'class', - content: [{ raw: html`
` }], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .foo { - @apply tw-peer; + @layer components { + .foo { + @apply peer; + } } - } - ` + ` - await expect(run(input, config)).rejects.toThrowError( - `@apply should not be used with the 'tw-peer' utility` - ) -}) + await expect(run(input, config)).rejects.toThrowError( + `@apply should not be used with the 'peer' utility` + ) + }) -test('@apply classes from outside a @layer', async () => { - let config = { - content: [{ raw: html`
` }], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - .foo { - @apply font-bold; + test('@apply error when using a prefixed .peer utility', async () => { + let config = { + prefix: 'tw-', + darkMode: 'class', + content: [{ raw: html`
` }], } - .bar { - @apply foo text-red-500 hover:text-green-500; - } + let input = css` + @tailwind components; + @tailwind utilities; - .baz { - @apply bar underline; - } - - .keep-me-even-though-I-am-not-used-in-content { - color: green; - } - ` - - await run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - .font-bold { - font-weight: 700; + @layer components { + .foo { + @apply tw-peer; + } } + ` + + await expect(run(input, config)).rejects.toThrowError( + `@apply should not be used with the 'tw-peer' utility` + ) + }) + + test('@apply classes from outside a @layer', async () => { + let config = { + content: [{ raw: html`
` }], + } + + let input = css` + @tailwind components; + @tailwind utilities; .foo { - font-weight: 700; + @apply font-bold; } .bar { - --tw-text-opacity: 1; - color: rgb(239 68 68 / var(--tw-text-opacity)); - font-weight: 700; - } - - .bar:hover { - --tw-text-opacity: 1; - color: rgb(34 197 94 / var(--tw-text-opacity)); + @apply foo text-red-500 hover:text-green-500; } .baz { - text-decoration-line: underline; - --tw-text-opacity: 1; - color: rgb(239 68 68 / var(--tw-text-opacity)); - font-weight: 700; - } - - .baz:hover { - --tw-text-opacity: 1; - color: rgb(34 197 94 / var(--tw-text-opacity)); + @apply bar underline; } .keep-me-even-though-I-am-not-used-in-content { color: green; } - `) + ` + + await run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .font-bold { + font-weight: 700; + } + + .foo { + font-weight: 700; + } + + .bar { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); + font-weight: 700; + } + + .bar:hover { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); + } + + .baz { + text-decoration-line: underline; + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); + font-weight: 700; + } + + .baz:hover { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); + } + + .keep-me-even-though-I-am-not-used-in-content { + color: green; + } + `) + }) }) -}) -test('@applying classes from outside a @layer respects the source order', async () => { - let config = { - content: [{ raw: html`
` }], - } - - let input = css` - .baz { - @apply bar underline; + test('@applying classes from outside a @layer respects the source order', async () => { + let config = { + content: [{ raw: html`
` }], } - @tailwind components; - - .keep-me-even-though-I-am-not-used-in-content { - color: green; - } - - @tailwind utilities; - - .foo { - @apply font-bold; - } - - .bar { - @apply no-underline; - } - ` - - await run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` + let input = css` .baz { - text-decoration-line: underline; - text-decoration-line: none; + @apply bar underline; } - .container { - width: 100%; - } - @media (min-width: 640px) { - .container { - max-width: 640px; - } - } - @media (min-width: 768px) { - .container { - max-width: 768px; - } - } - @media (min-width: 1024px) { - .container { - max-width: 1024px; - } - } - @media (min-width: 1280px) { - .container { - max-width: 1280px; - } - } - @media (min-width: 1536px) { - .container { - max-width: 1536px; - } - } + @tailwind components; .keep-me-even-though-I-am-not-used-in-content { color: green; } - .font-bold { - font-weight: 700; - } + @tailwind utilities; .foo { - font-weight: 700; + @apply font-bold; } .bar { - text-decoration-line: none; + @apply no-underline; } - `) + ` + + await run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .baz { + text-decoration-line: underline; + text-decoration-line: none; + } + + .container { + width: 100%; + } + @media (min-width: 640px) { + .container { + max-width: 640px; + } + } + @media (min-width: 768px) { + .container { + max-width: 768px; + } + } + @media (min-width: 1024px) { + .container { + max-width: 1024px; + } + } + @media (min-width: 1280px) { + .container { + max-width: 1280px; + } + } + @media (min-width: 1536px) { + .container { + max-width: 1536px; + } + } + + .keep-me-even-though-I-am-not-used-in-content { + color: green; + } + + .font-bold { + font-weight: 700; + } + + .foo { + font-weight: 700; + } + + .bar { + text-decoration-line: none; + } + `) + }) }) -}) -it('should remove duplicate properties when using apply with similar properties', () => { - let config = { - content: [{ raw: 'foo' }], - } - - let input = css` - @tailwind utilities; - - .foo { - @apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform; + it('should remove duplicate properties when using apply with similar properties', () => { + let config = { + content: [{ raw: 'foo' }], } - ` - return run(input, config).then((result) => { - expect(result.css).toMatchFormattedCss(css` + let input = css` + @tailwind utilities; + .foo { - position: absolute; - top: 50%; - left: 50%; - --tw-translate-x: -50%; - --tw-translate-y: -50%; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) - skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) - scaleY(var(--tw-scale-y)); + @apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform; } - `) + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .foo { + position: absolute; + top: 50%; + left: 50%; + --tw-translate-x: -50%; + --tw-translate-y: -50%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + } + `) + }) }) -}) -it('should apply all the definitions of a class', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer utilities { - .aspect-w-1 { - position: relative; - } - - .aspect-w-1 { - --tw-aspect-w: 1; - } + it('should apply all the definitions of a class', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], } - @layer components { - .foo { - @apply aspect-w-1; - } - } - ` + let input = css` + @tailwind components; + @tailwind utilities; - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - .foo { - position: relative; - --tw-aspect-w: 1; + @layer utilities { + .aspect-w-1 { + position: relative; + } + + .aspect-w-1 { + --tw-aspect-w: 1; + } } - `) + + @layer components { + .foo { + @apply aspect-w-1; + } + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .foo { + position: relative; + --tw-aspect-w: 1; + } + `) + }) }) -}) -it('should throw when trying to apply a direct circular dependency', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .foo:not(.text-red-500) { - @apply text-red-500; - } + it('should throw when trying to apply a direct circular dependency', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], } - ` - return run(input, config).catch((err) => { - expect(err.reason).toBe( - 'You cannot `@apply` the `text-red-500` utility here because it creates a circular dependency.' - ) + let input = css` + @tailwind components; + @tailwind utilities; + + @layer components { + .foo:not(.text-red-500) { + @apply text-red-500; + } + } + ` + + return run(input, config).catch((err) => { + expect(err.reason).toBe( + 'You cannot `@apply` the `text-red-500` utility here because it creates a circular dependency.' + ) + }) }) -}) -it('should throw when trying to apply an indirect circular dependency', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .a { - @apply b; - } - - .b { - @apply c; - } - - .c { - @apply a; - } + it('should throw when trying to apply an indirect circular dependency', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], } - ` - return run(input, config).catch((err) => { - expect(err.reason).toBe( - 'You cannot `@apply` the `a` utility here because it creates a circular dependency.' - ) + let input = css` + @tailwind components; + @tailwind utilities; + + @layer components { + .a { + @apply b; + } + + .b { + @apply c; + } + + .c { + @apply a; + } + } + ` + + return run(input, config).catch((err) => { + expect(err.reason).toBe( + 'You cannot `@apply` the `a` utility here because it creates a circular dependency.' + ) + }) }) -}) -it('should not throw when the selector is different (but contains the base partially)', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - .focus\:bg-gray-500 { - @apply bg-gray-500; + it('should not throw when the selector is different (but contains the base partially)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], } - ` - return run(input, config).then((result) => { - expect(result.css).toMatchFormattedCss(css` - .bg-gray-500 { - --tw-bg-opacity: 1; - background-color: rgb(107 114 128 / var(--tw-bg-opacity)); - } + let input = css` + @tailwind components; + @tailwind utilities; .focus\:bg-gray-500 { - --tw-bg-opacity: 1; - background-color: rgb(107 114 128 / var(--tw-bg-opacity)); + @apply bg-gray-500; } - `) + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); + } + + .focus\:bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); + } + `) + }) }) -}) -it('should throw when trying to apply an indirect circular dependency with a modifier (1)', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .a { - @apply b; - } - - .b { - @apply c; - } - - .c { - @apply hover:a; - } + it('should throw when trying to apply an indirect circular dependency with a modifier (1)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], } - ` - return run(input, config).catch((err) => { - expect(err.reason).toBe( - 'You cannot `@apply` the `hover:a` utility here because it creates a circular dependency.' - ) + let input = css` + @tailwind components; + @tailwind utilities; + + @layer components { + .a { + @apply b; + } + + .b { + @apply c; + } + + .c { + @apply hover:a; + } + } + ` + + return run(input, config).catch((err) => { + expect(err.reason).toBe( + 'You cannot `@apply` the `hover:a` utility here because it creates a circular dependency.' + ) + }) }) -}) -it('should throw when trying to apply an indirect circular dependency with a modifier (2)', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .a { - @apply b; - } - - .b { - @apply hover:c; - } - - .c { - @apply a; - } + it('should throw when trying to apply an indirect circular dependency with a modifier (2)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], } - ` - return run(input, config).catch((err) => { - expect(err.reason).toBe( - 'You cannot `@apply` the `a` utility here because it creates a circular dependency.' - ) + let input = css` + @tailwind components; + @tailwind utilities; + + @layer components { + .a { + @apply b; + } + + .b { + @apply hover:c; + } + + .c { + @apply a; + } + } + ` + + return run(input, config).catch((err) => { + expect(err.reason).toBe( + 'You cannot `@apply` the `a` utility here because it creates a circular dependency.' + ) + }) }) -}) -it('should not throw when the circular dependency is part of a different selector (1)', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } + it('should not throw when the circular dependency is part of a different selector (1)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } - let input = css` - @tailwind utilities; + let input = css` + @tailwind utilities; - @layer utilities { - html.dark .a, - .b { - color: red; + @layer utilities { + html.dark .a, + .b { + color: red; + } } - } - html.dark .c { - @apply b; - } - ` - - return run(input, config).then((result) => { - expect(result.css).toMatchFormattedCss(css` html.dark .c { - color: red; + @apply b; } - `) - }) -}) + ` -it('should not throw when the circular dependency is part of a different selector (2)', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind utilities; - - @layer utilities { - html.dark .a, - .b { - color: red; - } - } - - html.dark .c { - @apply hover:b; - } - ` - - return run(input, config).then((result) => { - expect(result.css).toMatchFormattedCss(css` - html.dark .c:hover { - color: red; - } - `) - }) -}) - -it('should throw when the circular dependency is part of the same selector', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind utilities; - - @layer utilities { - html.dark .a, - html.dark .b { - color: red; - } - } - - html.dark .c { - @apply hover:b; - } - ` - - return run(input, config).catch((err) => { - expect(err.reason).toBe( - 'You cannot `@apply` the `hover:b` utility here because it creates a circular dependency.' - ) - }) -}) - -it('rules with vendor prefixes are still separate when optimizing defaults rules', () => { - let config = { - experimental: { optimizeUniversalDefaults: true }, - content: [{ raw: html`
` }], - corePlugins: { preflight: false }, - } - - let input = css` - @tailwind base; - @tailwind components; - @tailwind utilities; - - @layer components { - input[type='range']::-moz-range-thumb { - @apply border; - } - } - ` - - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - input[type='range']::-moz-range-thumb { - border-width: 1px; - } - .border { - border-width: 1px; - } - `) - }) -}) - -it('should be possible to apply user css', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - .foo { - color: red; - } - - .bar { - @apply foo; - } - ` - - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - .foo { - color: red; - } - - .bar { - color: red; - } - `) - }) -}) - -it('should not be possible to apply user css with variants', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - .foo { - color: red; - } - - .bar { - @apply hover:foo; - } - ` - - return run(input, config).catch((err) => { - expect(err.reason).toBe( - 'The `hover:foo` class does not exist. If `hover:foo` is a custom class, make sure it is defined within a `@layer` directive.' - ) - }) -}) - -it('should not apply unrelated siblings when applying something from within atrules', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind components; - @tailwind utilities; - - @layer components { - .foo { - font-weight: bold; - @apply bar; - } - - .bar { - color: green; - } - - @supports (a: b) { - .bar { - color: blue; + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + html.dark .c { + color: red; } + `) + }) + }) - .something-unrelated { + it('should not throw when the circular dependency is part of a different selector (2)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind utilities; + + @layer utilities { + html.dark .a, + .b { color: red; } } - } - ` - return run(input, config).then((result) => { - expect(result.css).toMatchFormattedCss(css` - .foo { - font-weight: bold; - color: green; + html.dark .c { + @apply hover:b; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + html.dark .c:hover { + color: red; + } + `) + }) + }) + + it('should throw when the circular dependency is part of the same selector', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind utilities; + + @layer utilities { + html.dark .a, + html.dark .b { + color: red; + } } - @supports (a: b) { + html.dark .c { + @apply hover:b; + } + ` + + return run(input, config).catch((err) => { + expect(err.reason).toBe( + 'You cannot `@apply` the `hover:b` utility here because it creates a circular dependency.' + ) + }) + }) + + it('rules with vendor prefixes are still separate when optimizing defaults rules', () => { + let config = { + experimental: { optimizeUniversalDefaults: true }, + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + + @layer components { + input[type='range']::-moz-range-thumb { + @apply border; + } + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + input[type='range']::-moz-range-thumb { + border-width: 1px; + } + .border { + border-width: 1px; + } + `) + }) + }) + + it('should be possible to apply user css', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .foo { + color: red; + } + + .bar { + @apply foo; + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` .foo { - color: blue; + color: red; } + + .bar { + color: red; + } + `) + }) + }) + + it('should not be possible to apply user css with variants', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .foo { + color: red; } .bar { - color: green; + @apply hover:foo; } + ` - @supports (a: b) { - .bar { - color: blue; - } - - .something-unrelated { - color: red; - } - } - `) + return run(input, config).catch((err) => { + expect(err.reason).toBe( + 'The `hover:foo` class does not exist. If `hover:foo` is a custom class, make sure it is defined within a `@layer` directive.' + ) + }) }) -}) -it('should be possible to apply user css without tailwind directives', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } + it('should not apply unrelated siblings when applying something from within atrules', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } - let input = css` - .bop { - color: red; - } - .bar { - background-color: blue; - } - .foo { - @apply bar bop absolute; - } - ` + let input = css` + @tailwind components; + @tailwind utilities; - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` + @layer components { + .foo { + font-weight: bold; + @apply bar; + } + + .bar { + color: green; + } + + @supports (a: b) { + .bar { + color: blue; + } + + .something-unrelated { + color: red; + } + } + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .foo { + font-weight: bold; + color: green; + } + + @supports (a: b) { + .foo { + color: blue; + } + } + + .bar { + color: green; + } + + @supports (a: b) { + .bar { + color: blue; + } + + .something-unrelated { + color: red; + } + } + `) + }) + }) + + it('should be possible to apply user css without tailwind directives', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` .bop { color: red; } @@ -924,193 +915,170 @@ it('should be possible to apply user css without tailwind directives', () => { background-color: blue; } .foo { - position: absolute; - color: red; - background-color: blue; - } - `) - }) -}) - -it('should be possible to apply a class from another rule with multiple selectors (2 classes)', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind utilities; - @layer utilities { - .a, - .b { - @apply underline; - } - - .c { - @apply b; - } - } - ` - - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - .c { - text-decoration-line: underline; - } - `) - }) -}) - -it('should be possible to apply a class from another rule with multiple selectors (1 class, 1 tag)', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind utilities; - - @layer utilities { - span, - .b { - @apply underline; - } - - .c { - @apply b; - } - } - ` - - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - span, - .b { - text-decoration-line: underline; - } - - .c { - text-decoration-line: underline; - } - `) - }) -}) - -it('should be possible to apply a class from another rule with multiple selectors (1 class, 1 id)', () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind utilities; - @layer utilities { - #a, - .b { - @apply underline; - } - - .c { - @apply b; - } - } - ` - - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - #a, - .b { - text-decoration-line: underline; - } - - .c { - text-decoration-line: underline; - } - `) - }) -}) - -describe('multiple instances', () => { - it('should be possible to apply multiple "instances" of the same class', () => { - let config = { - content: [{ raw: html`` }], - plugins: [], - corePlugins: { preflight: false }, - } - - let input = css` - .a { - @apply b; - } - - .b { - @apply uppercase; - } - - .b { - color: red; + @apply bar bop absolute; } ` return run(input, config).then((result) => { return expect(result.css).toMatchFormattedCss(css` - .a { - text-transform: uppercase; + .bop { color: red; } - - .b { - text-transform: uppercase; + .bar { + background-color: blue; + } + .foo { + position: absolute; color: red; + background-color: blue; } `) }) }) - it('should be possible to apply a combination of multiple "instances" of the same class', () => { + it('should be possible to apply a class from another rule with multiple selectors (2 classes)', () => { let config = { - content: [{ raw: html`` }], + content: [{ raw: html`
` }], plugins: [], - corePlugins: { preflight: false }, } let input = css` - .a { - @apply b; - } + @tailwind utilities; + @layer utilities { + .a, + .b { + @apply underline; + } - .b { - @apply uppercase; - color: red; + .c { + @apply b; + } } ` return run(input, config).then((result) => { return expect(result.css).toMatchFormattedCss(css` - .a { - text-transform: uppercase; - color: red; - } - - .b { - text-transform: uppercase; - color: red; + .c { + text-decoration-line: underline; } `) }) }) - it('should generate the same output, even if it was used in a @layer', () => { + it('should be possible to apply a class from another rule with multiple selectors (1 class, 1 tag)', () => { let config = { - content: [{ raw: html`
` }], + content: [{ raw: html`
` }], plugins: [], - corePlugins: { preflight: false }, } let input = css` - @tailwind components; + @tailwind utilities; - @layer components { + @layer utilities { + span, + .b { + @apply underline; + } + + .c { + @apply b; + } + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + span, + .b { + text-decoration-line: underline; + } + + .c { + text-decoration-line: underline; + } + `) + }) + }) + + it('should be possible to apply a class from another rule with multiple selectors (1 class, 1 id)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind utilities; + @layer utilities { + #a, + .b { + @apply underline; + } + + .c { + @apply b; + } + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + #a, + .b { + text-decoration-line: underline; + } + + .c { + text-decoration-line: underline; + } + `) + }) + }) + + describe('multiple instances', () => { + it('should be possible to apply multiple "instances" of the same class', () => { + let config = { + content: [{ raw: html`` }], + plugins: [], + corePlugins: { preflight: false }, + } + + let input = css` + .a { + @apply b; + } + + .b { + @apply uppercase; + } + + .b { + color: red; + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .a { + text-transform: uppercase; + color: red; + } + + .b { + text-transform: uppercase; + color: red; + } + `) + }) + }) + + it('should be possible to apply a combination of multiple "instances" of the same class', () => { + let config = { + content: [{ raw: html`` }], + plugins: [], + corePlugins: { preflight: false }, + } + + let input = css` .a { @apply b; } @@ -1119,445 +1087,496 @@ describe('multiple instances', () => { @apply uppercase; color: red; } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .a { + text-transform: uppercase; + color: red; + } + + .b { + text-transform: uppercase; + color: red; + } + `) + }) + }) + + it('should generate the same output, even if it was used in a @layer', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind components; + + @layer components { + .a { + @apply b; + } + + .b { + @apply uppercase; + color: red; + } + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .a { + text-transform: uppercase; + color: red; + } + + .b { + text-transform: uppercase; + color: red; + } + `) + }) + }) + + it('should be possible to apply a combination of multiple "instances" of the same class (defined in a layer)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind components; + + @layer components { + .a { + color: red; + @apply b; + color: blue; + } + + .b { + @apply text-green-500; + text-decoration: underline; + } + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .a { + color: red; + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); + text-decoration: underline; + color: blue; + } + + .b { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); + text-decoration: underline; + } + `) + }) + }) + + it('should properly maintain the order', () => { + let config = { + content: [{ raw: html`` }], + plugins: [], + corePlugins: { preflight: false }, + } + + let input = css` + h2 { + @apply text-xl; + @apply lg:text-3xl; + @apply sm:text-2xl; + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + h2 { + font-size: 1.25rem; + line-height: 1.75rem; + } + + @media (min-width: 1024px) { + h2 { + font-size: 1.875rem; + line-height: 2.25rem; + } + } + + @media (min-width: 640px) { + h2 { + font-size: 1.5rem; + line-height: 2rem; + } + } + `) + }) + }) + }) + + it('apply can emit defaults in isolated environments without @tailwind directives', () => { + let config = { + experimental: { optimizeUniversalDefaults: true }, + + content: [{ raw: html`
` }], + } + + let input = css` + .foo { + @apply focus:rotate-90; } ` return run(input, config).then((result) => { return expect(result.css).toMatchFormattedCss(css` - .a { - text-transform: uppercase; - color: red; - } - - .b { - text-transform: uppercase; - color: red; + .foo:focus { + --tw-rotate: 90deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } `) }) }) - it('should be possible to apply a combination of multiple "instances" of the same class (defined in a layer)', () => { + it('apply does not emit defaults in isolated environments without optimizeUniversalDefaults', () => { let config = { - content: [{ raw: html`
` }], - plugins: [], + experimental: { optimizeUniversalDefaults: false }, + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + + .foo { + @apply focus:rotate-90; + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + ${defaults} + .foo:focus { + --tw-rotate: 90deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + } + `) + }) + }) + + it('should work outside of layer', async () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + } + + let input = css` + .input-text { + @apply bg-white; + background-color: red; + } + ` + + let result + result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .input-text { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + background-color: red; + } + `) + + result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .input-text { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + background-color: red; + } + `) + }) + + it('should work in layer', async () => { + let config = { + content: [{ raw: html`
` }], corePlugins: { preflight: false }, } let input = css` @tailwind components; - @layer components { - .a { - color: red; - @apply b; - color: blue; - } - - .b { - @apply text-green-500; - text-decoration: underline; + .input-text { + @apply bg-white; + background-color: red; } } ` - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - .a { - color: red; - --tw-text-opacity: 1; - color: rgb(34 197 94 / var(--tw-text-opacity)); - text-decoration: underline; - color: blue; - } + await run(input, config) + const result = await run(input, config) - .b { - --tw-text-opacity: 1; - color: rgb(34 197 94 / var(--tw-text-opacity)); - text-decoration: underline; - } - `) - }) + expect(result.css).toMatchFormattedCss(css` + .input-text { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + background-color: red; + } + `) }) - it('should properly maintain the order', () => { + it('apply partitioning works with media queries', async () => { let config = { content: [{ raw: html`` }], - plugins: [], corePlugins: { preflight: false }, } let input = css` - h2 { - @apply text-xl; - @apply lg:text-3xl; - @apply sm:text-2xl; + @tailwind base; + @layer base { + html, + body { + @apply text-green-600; + font-size: 1rem; + } + + @media print { + html, + body { + @apply text-red-600; + font-size: 2rem; + } + } } ` - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - h2 { - font-size: 1.25rem; - line-height: 1.75rem; - } + await run(input, config) + const result = await run(input, config) - @media (min-width: 1024px) { - h2 { - font-size: 1.875rem; - line-height: 2.25rem; - } - } - - @media (min-width: 640px) { - h2 { - font-size: 1.5rem; - line-height: 2rem; - } - } - `) - }) - }) -}) - -it('apply can emit defaults in isolated environments without @tailwind directives', () => { - let config = { - experimental: { optimizeUniversalDefaults: true }, - - content: [{ raw: html`
` }], - } - - let input = css` - .foo { - @apply focus:rotate-90; - } - ` - - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - .foo:focus { - --tw-rotate: 90deg; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) - skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) - scaleY(var(--tw-scale-y)); - } - `) - }) -}) - -it('apply does not emit defaults in isolated environments without optimizeUniversalDefaults', () => { - let config = { - experimental: { optimizeUniversalDefaults: false }, - content: [{ raw: html`
` }], - corePlugins: { preflight: false }, - } - - let input = css` - @tailwind base; - - .foo { - @apply focus:rotate-90; - } - ` - - return run(input, config).then((result) => { - return expect(result.css).toMatchFormattedCss(css` - ${defaults} - .foo:focus { - --tw-rotate: 90deg; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) - skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) - scaleY(var(--tw-scale-y)); - } - `) - }) -}) - -it('should work outside of layer', async () => { - let config = { - content: [{ raw: html`
` }], - corePlugins: { preflight: false }, - } - - let input = css` - .input-text { - @apply bg-white; - background-color: red; - } - ` - - let result - result = await run(input, config) - - expect(result.css).toMatchFormattedCss(css` - .input-text { - --tw-bg-opacity: 1; - background-color: rgb(255 255 255 / var(--tw-bg-opacity)); - background-color: red; - } - `) - - result = await run(input, config) - - expect(result.css).toMatchFormattedCss(css` - .input-text { - --tw-bg-opacity: 1; - background-color: rgb(255 255 255 / var(--tw-bg-opacity)); - background-color: red; - } - `) -}) - -it('should work in layer', async () => { - let config = { - content: [{ raw: html`
` }], - corePlugins: { preflight: false }, - } - - let input = css` - @tailwind components; - @layer components { - .input-text { - @apply bg-white; - background-color: red; - } - } - ` - - await run(input, config) - const result = await run(input, config) - - expect(result.css).toMatchFormattedCss(css` - .input-text { - --tw-bg-opacity: 1; - background-color: rgb(255 255 255 / var(--tw-bg-opacity)); - background-color: red; - } - `) -}) - -it('apply partitioning works with media queries', async () => { - let config = { - content: [{ raw: html`` }], - corePlugins: { preflight: false }, - } - - let input = css` - @tailwind base; - @layer base { + expect(result.css).toMatchFormattedCss(css` html, body { - @apply text-green-600; + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); font-size: 1rem; } @media print { html, body { - @apply text-red-600; + --tw-text-opacity: 1; + color: rgb(220 38 38 / var(--tw-text-opacity)); font-size: 2rem; } } - } - ` + ${defaults} + `) + }) - await run(input, config) - const result = await run(input, config) - - expect(result.css).toMatchFormattedCss(css` - html, - body { - --tw-text-opacity: 1; - color: rgb(22 163 74 / var(--tw-text-opacity)); - font-size: 1rem; + it('should be possible to use apply in plugins', async () => { + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + plugins: [ + function ({ addComponents }) { + addComponents({ + '.a': { + color: 'red', + }, + '.b': { + '@apply a': {}, + color: 'blue', + }, + }) + }, + ], } - @media print { - html, - body { - --tw-text-opacity: 1; - color: rgb(220 38 38 / var(--tw-text-opacity)); - font-size: 2rem; + return run('@tailwind components', config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .a { + color: red; + } + + .b { + color: red; + color: blue; + } + `) + }) + }) + + it('should apply using the updated user CSS when the source has changed', async () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let inputBefore = css` + .foo { + color: green; } - } - ${defaults} - `) -}) -it('should be possible to use apply in plugins', async () => { - let config = { - content: [{ raw: html`
` }], - corePlugins: { preflight: false }, - plugins: [ - function ({ addComponents }) { - addComponents({ - '.a': { - color: 'red', - }, - '.b': { - '@apply a': {}, - color: 'blue', - }, - }) - }, - ], - } + .bar { + @apply foo; + } + ` + + let inputAfter = css` + .foo { + color: red; + } + + .bar { + @apply foo; + } + ` + + let result = await run(inputBefore, config) - return run('@tailwind components', config).then((result) => { expect(result.css).toMatchFormattedCss(css` - .a { + .foo { + color: green; + } + + .bar { + color: green; + } + `) + + result = await run(inputAfter, config) + + expect(result.css).toMatchFormattedCss(css` + .foo { color: red; } - .b { + .bar { color: red; - color: blue; } `) }) -}) -it('should apply using the updated user CSS when the source has changed', async () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let inputBefore = css` - .foo { - color: green; + it('apply + layer utilities + selector variants (like group) + important selector', async () => { + let config = { + important: '#myselector', + content: [{ raw: html`
` }], + plugins: [], } - .bar { - @apply foo; - } - ` + let input = css` + @tailwind utilities; + @layer utilities { + .custom-utility { + @apply font-normal group-hover:underline; + } + } + ` - let inputAfter = css` - .foo { - color: red; + let result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + #myselector .custom-utility { + font-weight: 400; + } + + #myselector .group:hover .custom-utility { + text-decoration-line: underline; + } + `) + }) + + it('apply + user CSS + selector variants (like group) + important selector (1)', async () => { + let config = { + important: '#myselector', + content: [{ raw: html`
` }], + plugins: [], } - .bar { - @apply foo; - } - ` - - let result = await run(inputBefore, config) - - expect(result.css).toMatchFormattedCss(css` - .foo { - color: green; - } - - .bar { - color: green; - } - `) - - result = await run(inputAfter, config) - - expect(result.css).toMatchFormattedCss(css` - .foo { - color: red; - } - - .bar { - color: red; - } - `) -}) - -it('apply + layer utilities + selector variants (like group) + important selector', async () => { - let config = { - important: '#myselector', - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind utilities; - @layer utilities { + let input = css` .custom-utility { @apply font-normal group-hover:underline; } - } - ` + ` - let result = await run(input, config) + let result = await run(input, config) - expect(result.css).toMatchFormattedCss(css` - #myselector .custom-utility { - font-weight: 400; + expect(result.css).toMatchFormattedCss(css` + .custom-utility { + font-weight: 400; + } + + .group:hover .custom-utility { + text-decoration-line: underline; + } + `) + }) + + it('apply + user CSS + selector variants (like group) + important selector (2)', async () => { + let config = { + important: '#myselector', + content: [{ raw: html`
` }], + plugins: [], } - #myselector .group:hover .custom-utility { - text-decoration-line: underline; - } - `) -}) + let input = css` + #myselector .custom-utility { + @apply font-normal group-hover:underline; + } + ` -it('apply + user CSS + selector variants (like group) + important selector (1)', async () => { - let config = { - important: '#myselector', - content: [{ raw: html`
` }], - plugins: [], - } + let result = await run(input, config) - let input = css` - .custom-utility { - @apply font-normal group-hover:underline; - } - ` + expect(result.css).toMatchFormattedCss(css` + #myselector .custom-utility { + font-weight: 400; + } - let result = await run(input, config) + .group:hover #myselector .custom-utility { + text-decoration-line: underline; + } + `) + }) - expect(result.css).toMatchFormattedCss(css` - .custom-utility { - font-weight: 400; + it('can apply user utilities that start with a dash', async () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], } - .group:hover .custom-utility { - text-decoration-line: underline; - } - `) -}) + let input = css` + @tailwind utilities; + @layer utilities { + .foo-1 { + margin: 10px; + } + .-foo-1 { + margin: -15px; + } + .new-class { + @apply -foo-1; + } + } + ` -it('apply + user CSS + selector variants (like group) + important selector (2)', async () => { - let config = { - important: '#myselector', - content: [{ raw: html`
` }], - plugins: [], - } + let result = await run(input, config) - let input = css` - #myselector .custom-utility { - @apply font-normal group-hover:underline; - } - ` - - let result = await run(input, config) - - expect(result.css).toMatchFormattedCss(css` - #myselector .custom-utility { - font-weight: 400; - } - - .group:hover #myselector .custom-utility { - text-decoration-line: underline; - } - `) -}) - -it('can apply user utilities that start with a dash', async () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - @tailwind utilities; - @layer utilities { + expect(result.css).toMatchFormattedCss(css` .foo-1 { margin: 10px; } @@ -1565,182 +1584,168 @@ it('can apply user utilities that start with a dash', async () => { margin: -15px; } .new-class { - @apply -foo-1; + margin: -15px; } - } - ` + `) + }) - let result = await run(input, config) - - expect(result.css).toMatchFormattedCss(css` - .foo-1 { - margin: 10px; - } - .-foo-1 { - margin: -15px; - } - .new-class { - margin: -15px; - } - `) -}) - -it('can apply joined classes when using elements', async () => { - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - .foo.bar { - color: red; - } - .bar.foo { - color: green; - } - header:nth-of-type(odd) { - @apply foo; - } - header::after { - @apply foo; - } - main { - @apply foo bar; - } - footer { - @apply bar; - } - ` - - let result = await run(input, config) - - expect(result.css).toMatchFormattedCss(css` - .foo.bar { - color: red; - } - .bar.foo { - color: green; - } - header:nth-of-type(odd).bar { - color: red; - } - header.bar:nth-of-type(odd) { - color: green; - } - header.bar::after { - color: red; - color: green; - } - main.bar { - color: red; - } - main.foo { - color: red; - } - main.bar { - color: green; - } - main.foo { - color: green; - } - footer.foo { - color: red; - color: green; - } - `) -}) - -it('should not replace multiple instances of the same class in a single selector', async () => { - // NOTE: This test is non-normative and is not part of the spec of how `@apply` works per-se - // It describes how it currently works because the "correct" way produces a combinatorial explosion - // of selectors that is not easily doable - let config = { - content: [{ raw: html`
` }], - plugins: [], - } - - let input = css` - .foo + .foo { - color: blue; - } - .bar + .bar { - color: fuchsia; - } - header { - @apply foo; - } - main { - @apply foo bar; - } - footer { - @apply bar; - } - ` - - let result = await run(input, config) - - expect(result.css).toMatchFormattedCss(css` - .foo + .foo { - color: blue; - } - .bar + .bar { - color: fuchsia; - } - header + .foo { - color: blue; - } - main + .foo { - color: blue; - } - main + .bar { - color: fuchsia; - } - footer + .bar { - color: fuchsia; - } - `) -}) - -it('should maintain the correct selector when applying other utilities', () => { - let config = { - content: [ - { - raw: html` -
-
-
- `, - }, - ], - } - - let input = css` - @tailwind utilities; - - .foo:hover.bar .baz { - @apply bg-black; - color: red; + it('can apply joined classes when using elements', async () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], } - .foo:hover.bar > .baz { - @apply bg-black; - color: red; - } - ` + let input = css` + .foo.bar { + color: red; + } + .bar.foo { + color: green; + } + header:nth-of-type(odd) { + @apply foo; + } + header::after { + @apply foo; + } + main { + @apply foo bar; + } + footer { + @apply bar; + } + ` + + let result = await run(input, config) - return run(input, config).then((result) => { expect(result.css).toMatchFormattedCss(css` + .foo.bar { + color: red; + } + .bar.foo { + color: green; + } + header:nth-of-type(odd).bar { + color: red; + } + header.bar:nth-of-type(odd) { + color: green; + } + header.bar::after { + color: red; + color: green; + } + main.bar { + color: red; + } + main.foo { + color: red; + } + main.bar { + color: green; + } + main.foo { + color: green; + } + footer.foo { + color: red; + color: green; + } + `) + }) + + it('should not replace multiple instances of the same class in a single selector', async () => { + // NOTE: This test is non-normative and is not part of the spec of how `@apply` works per-se + // It describes how it currently works because the "correct" way produces a combinatorial explosion + // of selectors that is not easily doable + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + .foo + .foo { + color: blue; + } + .bar + .bar { + color: fuchsia; + } + header { + @apply foo; + } + main { + @apply foo bar; + } + footer { + @apply bar; + } + ` + + let result = await run(input, config) + + expect(result.css).toMatchFormattedCss(css` + .foo + .foo { + color: blue; + } + .bar + .bar { + color: fuchsia; + } + header + .foo { + color: blue; + } + main + .foo { + color: blue; + } + main + .bar { + color: fuchsia; + } + footer + .bar { + color: fuchsia; + } + `) + }) + + it('should maintain the correct selector when applying other utilities', () => { + let config = { + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + } + + let input = css` + @tailwind utilities; + .foo:hover.bar .baz { - --tw-bg-opacity: 1; - background-color: rgb(0 0 0 / var(--tw-bg-opacity)); + @apply bg-black; color: red; } .foo:hover.bar > .baz { - --tw-bg-opacity: 1; - background-color: rgb(0 0 0 / var(--tw-bg-opacity)); + @apply bg-black; color: red; } - `) + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .foo:hover.bar .baz { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); + color: red; + } + + .foo:hover.bar > .baz { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); + color: red; + } + `) + }) }) }) diff --git a/tests/arbitrary-values.test.css b/tests/arbitrary-values.test.css index 4718c2cc5..b23ced36c 100644 --- a/tests/arbitrary-values.test.css +++ b/tests/arbitrary-values.test.css @@ -685,13 +685,7 @@ .bg-\[image\:var\(--value\)\2c var\(--value\)\] { background-image: var(--value), var(--value); } -.bg-\[linear-gradient\(\#eee\2c - \#fff\)\2c - conic-gradient\(red\2c - orange\2c - yellow\2c - green\2c - blue\)\] { +.bg-\[linear-gradient\(\#eee\, \#fff\)\, conic-gradient\(red\, orange\, yellow\, green\, blue\)\] { background-image: linear-gradient(#eee, #fff), conic-gradient(red, orange, yellow, green, blue); } .bg-\[linear-gradient\(\#eee\2c \#fff\)\] { diff --git a/tests/arbitrary-variants.test.js b/tests/arbitrary-variants.test.js index a770efd98..93ccb558f 100644 --- a/tests/arbitrary-variants.test.js +++ b/tests/arbitrary-variants.test.js @@ -666,45 +666,21 @@ it('should support aria variants', () => { return run(input, config).then((result) => { expect(result.css).toMatchFormattedCss(css` - .aria-checked\:underline[aria-checked='true'] { - text-decoration-line: underline; - } - .aria-\[sort\=ascending\]\:underline[aria-sort='ascending'] { - text-decoration-line: underline; - } - .aria-\[labelledby\=a_b\]\:underline[aria-labelledby='a b'] { - text-decoration-line: underline; - } - .group[aria-checked='true'] .group-aria-checked\:underline { - text-decoration-line: underline; - } - .group\/foo[aria-checked='true'] .group-aria-checked\/foo\:underline { - text-decoration-line: underline; - } - .group[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\:underline { - text-decoration-line: underline; - } - .group[aria-labelledby='a b'] .group-aria-\[labelledby\=a_b\]\:underline { - text-decoration-line: underline; - } - .group\/foo[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\/foo\:underline { - text-decoration-line: underline; - } - .peer[aria-checked='true'] ~ .peer-aria-checked\:underline { - text-decoration-line: underline; - } - .peer\/foo[aria-checked='true'] ~ .peer-aria-checked\/foo\:underline { - text-decoration-line: underline; - } + .aria-checked\:underline[aria-checked='true'], + .aria-\[labelledby\=a_b\]\:underline[aria-labelledby='a b'], + .aria-\[sort\=ascending\]\:underline[aria-sort='ascending'], + .group\/foo[aria-checked='true'] .group-aria-checked\/foo\:underline, + .group[aria-checked='true'] .group-aria-checked\:underline, + .group[aria-labelledby='a b'] .group-aria-\[labelledby\=a_b\]\:underline, + .group\/foo[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\/foo\:underline, + .group[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\:underline, + .peer\/foo[aria-checked='true'] ~ .peer-aria-checked\/foo\:underline, + .peer[aria-checked='true'] ~ .peer-aria-checked\:underline, + .peer[aria-labelledby='a b'] ~ .peer-aria-\[labelledby\=a_b\]\:underline, + .peer\/foo[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\/foo\:underline, .peer[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\:underline { text-decoration-line: underline; } - .peer[aria-labelledby='a b'] ~ .peer-aria-\[labelledby\=a_b\]\:underline { - text-decoration-line: underline; - } - .peer\/foo[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\/foo\:underline { - text-decoration-line: underline; - } `) }) }) @@ -746,45 +722,21 @@ it('should support data variants', () => { return run(input, config).then((result) => { expect(result.css).toMatchFormattedCss(css` - .data-checked\:underline[data-ui~='checked'] { - text-decoration-line: underline; - } - .data-\[position\=top\]\:underline[data-position='top'] { - text-decoration-line: underline; - } - .data-\[foo\=bar_baz\]\:underline[data-foo='bar baz'] { - text-decoration-line: underline; - } - .group[data-ui~='checked'] .group-data-checked\:underline { - text-decoration-line: underline; - } - .group\/foo[data-ui~='checked'] .group-data-checked\/foo\:underline { - text-decoration-line: underline; - } - .group[data-position='top'] .group-data-\[position\=top\]\:underline { - text-decoration-line: underline; - } - .group[data-foo='bar baz'] .group-data-\[foo\=bar_baz\]\:underline { - text-decoration-line: underline; - } - .group\/foo[data-position='top'] .group-data-\[position\=top\]\/foo\:underline { - text-decoration-line: underline; - } - .peer[data-ui~='checked'] ~ .peer-data-checked\:underline { - text-decoration-line: underline; - } - .peer\/foo[data-ui~='checked'] ~ .peer-data-checked\/foo\:underline { - text-decoration-line: underline; - } + .data-checked\:underline[data-ui~='checked'], + .data-\[foo\=bar_baz\]\:underline[data-foo='bar baz'], + .data-\[position\=top\]\:underline[data-position='top'], + .group\/foo[data-ui~='checked'] .group-data-checked\/foo\:underline, + .group[data-ui~='checked'] .group-data-checked\:underline, + .group[data-foo='bar baz'] .group-data-\[foo\=bar_baz\]\:underline, + .group\/foo[data-position='top'] .group-data-\[position\=top\]\/foo\:underline, + .group[data-position='top'] .group-data-\[position\=top\]\:underline, + .peer\/foo[data-ui~='checked'] ~ .peer-data-checked\/foo\:underline, + .peer[data-ui~='checked'] ~ .peer-data-checked\:underline, + .peer[data-foo='bar baz'] ~ .peer-data-\[foo\=bar_baz\]\:underline, + .peer\/foo[data-position='top'] ~ .peer-data-\[position\=top\]\/foo\:underline, .peer[data-position='top'] ~ .peer-data-\[position\=top\]\:underline { text-decoration-line: underline; } - .peer[data-foo='bar baz'] ~ .peer-data-\[foo\=bar_baz\]\:underline { - text-decoration-line: underline; - } - .peer\/foo[data-position='top'] ~ .peer-data-\[position\=top\]\/foo\:underline { - text-decoration-line: underline; - } `) }) }) @@ -833,28 +785,14 @@ it('should support supports', () => { .supports-grid\:underline { text-decoration-line: underline; } - } - @supports (display: grid) { .supports-\[display\:grid\]\:grid { display: grid; } } - @supports (transform-origin: 5% 5%) { - .supports-\[transform-origin\:5\%_5\%\]\:underline { - text-decoration-line: underline; - } - } - - @supports selector(A > B) { - .supports-\[selector\(A\>B\)\]\:underline { - text-decoration-line: underline; - } - } - - @supports not (foo: bar) { - .supports-\[not\(foo\:bar\)\]\:underline { + @supports (foo: bar) and (bar: baz) { + .supports-\[\(foo\:bar\)and\(bar\:baz\)\]\:underline { text-decoration-line: underline; } } @@ -865,14 +803,26 @@ it('should support supports', () => { } } - @supports (foo: bar) and (bar: baz) { - .supports-\[\(foo\:bar\)and\(bar\:baz\)\]\:underline { + @supports (container-type: var(--tw)) { + .supports-\[container-type\]\:underline { text-decoration-line: underline; } } - @supports (container-type: var(--tw)) { - .supports-\[container-type\]\:underline { + @supports not (foo: bar) { + .supports-\[not\(foo\:bar\)\]\:underline { + text-decoration-line: underline; + } + } + + @supports selector(A > B) { + .supports-\[selector\(A\>B\)\]\:underline { + text-decoration-line: underline; + } + } + + @supports (transform-origin: 5% 5%) { + .supports-\[transform-origin\:5\%_5\%\]\:underline { text-decoration-line: underline; } } @@ -935,48 +885,22 @@ it('should be possible to use modifiers and arbitrary groups', () => { return run(input, config).then((result) => { expect(result.css).toMatchFormattedCss(css` - .group:hover .group-hover\:underline { - text-decoration-line: underline; - } - .group\/foo:hover .group-hover\/foo\:underline { - text-decoration-line: underline; - } - .group:focus .group-\[\&\:focus\]\:underline { - text-decoration-line: underline; - } - .group:hover .group-\[\:hover\]\:underline { - text-decoration-line: underline; - } - .group[data-open] .group-\[\&\[data-open\]\]\:underline { - text-decoration-line: underline; - } + .group\/foo:hover .group-hover\/foo\:underline, + .group:hover .group-hover\:underline, + .group\/foo:focus .group-\[\&\:focus\]\/foo\:underline, + .group:focus .group-\[\&\:focus\]\:underline, + .group\/foo[data-open] .group-\[\&\[data-open\]\]\/foo\:underline, + .group[data-open] .group-\[\&\[data-open\]\]\:underline, + .group\/foo.in-foo .group-\[\.in-foo\]\/foo\:underline, + .group.in-foo .group-\[\.in-foo\]\:underline, + .in-foo .group\/foo .group-\[\.in-foo_\&\]\/foo\:underline, + .in-foo .group .group-\[\.in-foo_\&\]\:underline, + .group\/foo:hover .group-\[\:hover\]\/foo\:underline, + .group:hover .group-\[\:hover\]\:underline, + .group\/foo[data-open] .group-\[\[data-open\]\]\/foo\:underline, .group[data-open] .group-\[\[data-open\]\]\:underline { text-decoration-line: underline; } - .in-foo .group .group-\[\.in-foo_\&\]\:underline { - text-decoration-line: underline; - } - .group.in-foo .group-\[\.in-foo\]\:underline { - text-decoration-line: underline; - } - .group\/foo:focus .group-\[\&\:focus\]\/foo\:underline { - text-decoration-line: underline; - } - .group\/foo:hover .group-\[\:hover\]\/foo\:underline { - text-decoration-line: underline; - } - .group\/foo[data-open] .group-\[\&\[data-open\]\]\/foo\:underline { - text-decoration-line: underline; - } - .group\/foo[data-open] .group-\[\[data-open\]\]\/foo\:underline { - text-decoration-line: underline; - } - .in-foo .group\/foo .group-\[\.in-foo_\&\]\/foo\:underline { - text-decoration-line: underline; - } - .group\/foo.in-foo .group-\[\.in-foo\]\/foo\:underline { - text-decoration-line: underline; - } `) }) }) @@ -1036,48 +960,22 @@ it('should be possible to use modifiers and arbitrary peers', () => { return run(input, config).then((result) => { expect(result.css).toMatchFormattedCss(css` - .peer:hover ~ .peer-hover\:underline { - text-decoration-line: underline; - } - .peer\/foo:hover ~ .peer-hover\/foo\:underline { - text-decoration-line: underline; - } - .peer:focus ~ .peer-\[\&\:focus\]\:underline { - text-decoration-line: underline; - } - .peer:hover ~ .peer-\[\:hover\]\:underline { - text-decoration-line: underline; - } - .peer[data-open] ~ .peer-\[\&\[data-open\]\]\:underline { - text-decoration-line: underline; - } + .peer\/foo:hover ~ .peer-hover\/foo\:underline, + .peer:hover ~ .peer-hover\:underline, + .peer\/foo:focus ~ .peer-\[\&\:focus\]\/foo\:underline, + .peer:focus ~ .peer-\[\&\:focus\]\:underline, + .peer\/foo[data-open] ~ .peer-\[\&\[data-open\]\]\/foo\:underline, + .peer[data-open] ~ .peer-\[\&\[data-open\]\]\:underline, + .peer\/foo.in-foo ~ .peer-\[\.in-foo\]\/foo\:underline, + .peer.in-foo ~ .peer-\[\.in-foo\]\:underline, + .in-foo .peer\/foo ~ .peer-\[\.in-foo_\&\]\/foo\:underline, + .in-foo .peer ~ .peer-\[\.in-foo_\&\]\:underline, + .peer\/foo:hover ~ .peer-\[\:hover\]\/foo\:underline, + .peer:hover ~ .peer-\[\:hover\]\:underline, + .peer\/foo[data-open] ~ .peer-\[\[data-open\]\]\/foo\:underline, .peer[data-open] ~ .peer-\[\[data-open\]\]\:underline { text-decoration-line: underline; } - .in-foo .peer ~ .peer-\[\.in-foo_\&\]\:underline { - text-decoration-line: underline; - } - .peer.in-foo ~ .peer-\[\.in-foo\]\:underline { - text-decoration-line: underline; - } - .peer\/foo:focus ~ .peer-\[\&\:focus\]\/foo\:underline { - text-decoration-line: underline; - } - .peer\/foo:hover ~ .peer-\[\:hover\]\/foo\:underline { - text-decoration-line: underline; - } - .peer\/foo[data-open] ~ .peer-\[\&\[data-open\]\]\/foo\:underline { - text-decoration-line: underline; - } - .peer\/foo[data-open] ~ .peer-\[\[data-open\]\]\/foo\:underline { - text-decoration-line: underline; - } - .in-foo .peer\/foo ~ .peer-\[\.in-foo_\&\]\/foo\:underline { - text-decoration-line: underline; - } - .peer\/foo.in-foo ~ .peer-\[\.in-foo\]\/foo\:underline { - text-decoration-line: underline; - } `) }) }) diff --git a/tests/basic-usage.test.js b/tests/basic-usage.test.js index d310fc0e3..bc6f2f736 100644 --- a/tests/basic-usage.test.js +++ b/tests/basic-usage.test.js @@ -306,7 +306,7 @@ it('can parse box shadows with variables', () => { content: [{ raw: html`
` }], theme: { boxShadow: { - lg: 'var(-a, 0 35px 60px -15px rgba(0, 0, 0)), 0 0 1px rgb(0, 0, 0)', + lg: 'var(--a, 0 35px 60px -15px rgba(0, 0, 0)), 0 0 1px rgb(0, 0, 0)', }, }, corePlugins: { preflight: false }, @@ -319,7 +319,7 @@ it('can parse box shadows with variables', () => { return run(input, config).then((result) => { expect(result.css).toMatchFormattedCss(css` .shadow-lg { - --tw-shadow: var(-a, 0 35px 60px -15px rgba(0, 0, 0)), 0 0 1px rgb(0, 0, 0); + --tw-shadow: var(--a, 0 35px 60px -15px rgba(0, 0, 0)), 0 0 1px rgb(0, 0, 0); --tw-shadow-colored: 0 35px 60px -15px var(--tw-shadow-color), 0 0 1px var(--tw-shadow-color); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), @@ -372,12 +372,11 @@ it('supports multiple backgrounds as arbitrary values even if only some are quot return run(input, config).then((result) => { expect(result.css).toMatchFormattedCss(css` - .bg-\[url\(\'\/images\/one-two-three\.png\'\)\2c - linear-gradient\(to_right\2c - _\#eeeeee\2c + .bg-\[url\(\'\/images\/one-two-three\.png\'\)\, + linear-gradient\(to_right\, + _\#eeeeee\, _\#000000\)\] { - background-image: url('/images/one-two-three.png'), - linear-gradient(to right, #eeeeee, #000000); + background-image: url('/images/one-two-three.png'), linear-gradient(to right, #eee, #000); } `) }) diff --git a/tests/detect-nesting.test.js b/tests/detect-nesting.test.js index 08eeacf98..06dab2f33 100644 --- a/tests/detect-nesting.test.js +++ b/tests/detect-nesting.test.js @@ -1,128 +1,135 @@ import { run, html, css } from './util/run' +import { env } from '../src/lib/sharedState' -it('should warn when we detect nested css', () => { - let config = { - content: [{ raw: html`
` }], - } +// This is only an issue in the non-oxide version. In fact, we _do_ want nesting in the oxide +// version! +let group = env.OXIDE ? describe.skip : describe - let input = css` - @tailwind utilities; - - .nested { - .example { - } +group('detect-nesting', () => { + it('should warn when we detect nested css', () => { + let config = { + content: [{ raw: html`
` }], } - ` - return run(input, config).then((result) => { - expect(result.messages).toHaveLength(1) - expect(result.messages).toMatchObject([ - { - type: 'warning', - text: [ - 'Nested CSS was detected, but CSS nesting has not been configured correctly.', - 'Please enable a CSS nesting plugin *before* Tailwind in your configuration.', - 'See how here: https://tailwindcss.com/docs/using-with-preprocessors#nesting', - ].join('\n'), - }, - ]) - }) -}) - -it('should not warn when we detect nested css inside css @layer rules', () => { - let config = { - content: [{ raw: html`
` }], - } - - let input = css` - @layer tw-base, tw-components, tw-utilities; - @layer tw-utilities { + let input = css` @tailwind utilities; - } - ` - return run(input, config).then((result) => { - expect(result.css).toMatchFormattedCss(css` - @layer tw-base, tw-components, tw-utilities; - @layer tw-utilities { - .underline { - text-decoration-line: underline; + .nested { + .example { } } - `) - expect(result.messages).toHaveLength(0) + ` + + return run(input, config).then((result) => { + expect(result.messages).toHaveLength(1) + expect(result.messages).toMatchObject([ + { + type: 'warning', + text: [ + 'Nested CSS was detected, but CSS nesting has not been configured correctly.', + 'Please enable a CSS nesting plugin *before* Tailwind in your configuration.', + 'See how here: https://tailwindcss.com/docs/using-with-preprocessors#nesting', + ].join('\n'), + }, + ]) + }) }) -}) -it('should warn when we detect namespaced @tailwind at rules', () => { - let config = { - content: [{ raw: html`
` }], - } - - let input = css` - .namespace { - @tailwind utilities; + it('should not warn when we detect nested css inside css @layer rules', () => { + let config = { + content: [{ raw: html`
` }], } - ` - return run(input, config).then((result) => { - expect(result.messages).toHaveLength(1) - expect(result.messages).toMatchObject([ - { - type: 'warning', - text: [ - 'Nested @tailwind rules were detected, but are not supported.', - "Consider using a prefix to scope Tailwind's classes: https://tailwindcss.com/docs/configuration#prefix", - 'Alternatively, use the important selector strategy: https://tailwindcss.com/docs/configuration#selector-strategy', - ].join('\n'), - }, - ]) - }) -}) - -it('should not warn when nesting a single rule inside a media query', () => { - let config = { - content: [{ raw: html`
` }], - } - - let input = css` - @tailwind utilities; - - @media (min-width: 768px) { - .nested { + let input = css` + @layer tw-base, tw-components, tw-utilities; + @layer tw-utilities { + @tailwind utilities; } - } - ` + ` - return run(input, config).then((result) => { - expect(result.messages).toHaveLength(0) - expect(result.messages).toEqual([]) + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @layer tw-base, tw-components, tw-utilities; + @layer tw-utilities { + .underline { + text-decoration-line: underline; + } + } + `) + expect(result.messages).toHaveLength(0) + }) }) -}) -it('should only warn for the first detected nesting ', () => { - let config = { - content: [{ raw: html`
` }], - } + it('should warn when we detect namespaced @tailwind at rules', () => { + let config = { + content: [{ raw: html`
` }], + } - let input = css` - @tailwind utilities; + let input = css` + .namespace { + @tailwind utilities; + } + ` - .nested { - .example { + return run(input, config).then((result) => { + expect(result.messages).toHaveLength(1) + expect(result.messages).toMatchObject([ + { + type: 'warning', + text: [ + 'Nested @tailwind rules were detected, but are not supported.', + "Consider using a prefix to scope Tailwind's classes: https://tailwindcss.com/docs/configuration#prefix", + 'Alternatively, use the important selector strategy: https://tailwindcss.com/docs/configuration#selector-strategy', + ].join('\n'), + }, + ]) + }) + }) + + it('should not warn when nesting a single rule inside a media query', () => { + let config = { + content: [{ raw: html`
` }], + } + + let input = css` + @tailwind utilities; + + @media (min-width: 768px) { + .nested { + } + } + ` + + return run(input, config).then((result) => { + expect(result.messages).toHaveLength(0) + expect(result.messages).toEqual([]) + }) + }) + + it('should only warn for the first detected nesting ', () => { + let config = { + content: [{ raw: html`
` }], + } + + let input = css` + @tailwind utilities; + + .nested { + .example { + } + + .other { + } } .other { + .example { + } } - } + ` - .other { - .example { - } - } - ` - - return run(input, config).then((result) => { - expect(result.messages).toHaveLength(1) + return run(input, config).then((result) => { + expect(result.messages).toHaveLength(1) + }) }) }) diff --git a/tests/generate-rules.test.js b/tests/generate-rules.test.js index a997a46cd..f929bac01 100644 --- a/tests/generate-rules.test.js +++ b/tests/generate-rules.test.js @@ -1,4 +1,5 @@ import { css } from './util/run' +import selectorParser from 'postcss-selector-parser' import { generateRules } from '../src/lib/generateRules' import resolveConfig from '../src/public/resolve-config' @@ -28,6 +29,21 @@ it('should not generate rules that are incorrect', () => { context ) + // TODO: This is the `remove :merge` logic, move this to a common spot? + // Or maybe even update this tests to test more high-level? + rules[0][1].walkRules((rule) => { + if (rule.selector.includes(':merge')) { + let selectorFunctions = new Set([':merge']) + let ast = selectorParser().astSync(rule.selector) + ast.walkPseudos((node) => { + if (selectorFunctions.has(node.value)) { + node.replaceWith(node.nodes) + } + }) + rule.selector = ast.toString() + } + }) + // Ensure we only have 2 valid rules expect(rules).toHaveLength(2) diff --git a/tests/modify-selectors.test.js b/tests/modify-selectors.test.js index a586415ef..44ba95598 100644 --- a/tests/modify-selectors.test.js +++ b/tests/modify-selectors.test.js @@ -3,8 +3,12 @@ import path from 'path' import selectorParser from 'postcss-selector-parser' import { run, css } from './util/run' +import { env } from '../src/lib/sharedState' -test('modify selectors', () => { +// modifySelectors is not supported in the OXIDE version +let t = env.OXIDE ? test.skip : test + +t('modify selectors', () => { let config = { darkMode: 'class', content: [path.resolve(__dirname, './modify-selectors.test.html')], diff --git a/tests/oxide.apply.test.js b/tests/oxide.apply.test.js new file mode 100644 index 000000000..436469c36 --- /dev/null +++ b/tests/oxide.apply.test.js @@ -0,0 +1,484 @@ +import { run, html, css } from './util/run' +import { env } from '../src/lib/sharedState' + +let group = env.OXIDE ? describe : describe.skip + +group('@apply', () => { + it('should apply simple utilities', async () => { + let config = { + content: [{ raw: html`` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply p-2 bg-white rounded-md; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + border-radius: 0.375rem; + padding: 0.5rem; + } + `) + }) + }) + + it('should apply arbitrary properties', async () => { + let config = { + content: [{ raw: html`` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply [color:red] [display:none]; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + color: red; + display: none; + } + `) + }) + }) + + it('should apply arbitrary values', async () => { + let config = { + content: [{ raw: html`` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply p-[12px] m-[4px]; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + margin: 4px; + padding: 12px; + } + `) + }) + }) + + it('should apply user css that also exists as a utility', async () => { + let config = { + content: [{ raw: html`` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind components; + @tailwind utilities; + + @layer utilities { + .p-2 { + color: red; + } + } + + .btn { + @apply p-2; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + color: red; + padding: 0.5rem; + } + `) + }) + }) + + it('should apply utilities with simple variants', async () => { + let config = { + content: [{ raw: html`` }], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply p-2 bg-white rounded-full hover:rounded-md; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + border-radius: 9999px; + padding: 0.5rem; + } + + .btn:hover { + border-radius: 0.375rem; + } + `) + }) + }) + + it('should apply utilities with arbitrary variants', async () => { + let config = { + content: [{ raw: html`` }], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply p-2 bg-white rounded-full [&_.foo]:rounded-md; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + border-radius: 9999px; + padding: 0.5rem; + } + + .btn .foo { + border-radius: 0.375rem; + } + `) + }) + }) + + it('should apply utilities with stacked arbitrary variants', async () => { + let config = { + content: [{ raw: html`` }], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply p-2 bg-white rounded-full [.foo_&]:[&_.bar]:rounded-md; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + border-radius: 9999px; + padding: 0.5rem; + } + + .foo :is(.btn .bar) { + border-radius: 0.375rem; + } + `) + }) + }) + + it('should apply atrule variants', async () => { + let config = { + content: [{ raw: html`` }], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply p-2 md:p-4; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + padding: 0.5rem; + } + + @media (min-width: 768px) { + .btn { + padding: 1rem; + } + } + `) + }) + }) + + it('should apply variants from external plugins with internal variants', async () => { + let config = { + content: [{ raw: html`` }], + plugins: [ + function ({ addVariant }) { + addVariant('my-variant', '@supports (display: grid) { @media print { &::orange }}') + }, + ], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply italic md:my-variant:hover:underline; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + font-style: italic; + } + + @supports (display: grid) { + @media print { + @media (min-width: 768px) { + .btn:hover::orange { + text-decoration-line: underline; + } + } + } + } + `) + }) + }) + + it('should apply utilities with multiple variants', async () => { + let config = { + content: [{ raw: html`` }], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply p-2 bg-white rounded-full focus:hover:rounded-md; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + border-radius: 9999px; + padding: 0.5rem; + } + + .btn:hover:focus { + border-radius: 0.375rem; + } + `) + }) + }) + + it('should apply utilities with parallel variants', async () => { + let config = { + content: [{ raw: html`` }], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply p-2 bg-white rounded-full marker:rounded-md; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + border-radius: 9999px; + padding: 0.5rem; + } + + .btn ::marker, + .btn::marker { + border-radius: 0.375rem; + } + `) + }) + }) + + it('should apply utilities with stacked and parallel variants', async () => { + let config = { + content: [{ raw: html`` }], + plugins: [ + function ({ addVariant }) { + addVariant('foo', ['.foo_1 &', '.foo_2 &']) + addVariant('bar', ['.bar_1 &', '.bar_2 &']) + }, + ], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply p-2 bg-white rounded-full focus:foo:hover:bar:rounded-md; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + border-radius: 9999px; + padding: 0.5rem; + } + + .foo_1 .bar_1 .btn:hover:focus, + .foo_2 .bar_1 .btn:hover:focus, + .foo_1 .bar_2 .btn:hover:focus, + .foo_2 .bar_2 .btn:hover:focus { + border-radius: 0.375rem; + } + `) + }) + }) + + it('should apply user css', async () => { + let config = { + content: [{ raw: html`` }], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + @layer utilities { + .custom-util { + color: red; + + &:hover { + color: blue; + } + } + } + + .btn { + @apply custom-util; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + color: red; + } + + .btn:hover { + color: #00f; + } + `) + }) + }) + + it('should apply user css that itself contains @apply rules', async () => { + let config = { + content: [{ raw: html`` }], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + @layer utilities { + .custom-util { + @apply p-2 underline; + + &:hover { + @apply md:font-bold; + } + } + } + + .btn { + @apply custom-util; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss( + css` + .btn { + padding: 0.5rem; + text-decoration-line: underline; + } + + @media (min-width: 768px) { + .btn:hover { + font-weight: 700; + } + } + ` + ) + }) + }) + + it('should mark utils as important when using !important', async () => { + let config = { + content: [{ raw: html`` }], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .btn { + @apply font-bold underline !important; + } + + /* Mutability check: Ensuring this is generated _without_ the !important */ + .btn-2 { + @apply underline; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .btn { + font-weight: 700 !important; + text-decoration-line: underline !important; + } + + .btn-2 { + text-decoration-line: underline; + } + `) + }) + }) +}) diff --git a/tests/oxide.test.js b/tests/oxide.test.js index 75fa4b2c9..e0dc9b402 100644 --- a/tests/oxide.test.js +++ b/tests/oxide.test.js @@ -2,7 +2,7 @@ import fs from 'fs' import path from 'path' import { env } from '../src/lib/sharedState' -import { run, css } from './util/run' +import { run, html, css } from './util/run' afterEach(() => { env.OXIDE = false @@ -35,7 +35,8 @@ test.each([[true], [false]])('it works (using Rust: %p)', (useOxide) => { 'foo', ({ container }) => { container.walkRules((rule) => { - rule.selector = `.foo\\:${rule.selector.slice(1)}` + // TODO: Document that this is bad? + // rule.selector = `.foo\\:${rule.selector.slice(1)}` rule.walkDecls((decl) => { decl.important = true }) @@ -200,3 +201,63 @@ xit.each([[true], [false]])( }) } ) + +it.each([[true], [false]])('it works as well (using Rust: %p)', (useOxide) => { + env.OXIDE = useOxide + + let config = { + content: [{ raw: html`
` }], + corePlugins: { preflight: false }, + theme: { + extend: { + screens: { + range: { min: '1280px', max: '1535px' }, + multi: [{ min: '640px', max: '767px' }, { max: '868px' }], + }, + }, + }, + } + + let input = css` + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 640px) and (max-width: 767px), (max-width: 868px) { + .multi\:text-left { + text-align: left; + } + } + `) + }) +}) + +xit.each([[true], [false]])( + 'should use the transformer for svelte files (using Rust: %p)', + (useOxide) => { + env.OXIDE = useOxide + + let config = { + darkMode: 'class', + content: [path.resolve(__dirname, './oxide.test.svelte')], + corePlugins: { preflight: false }, + theme: {}, + plugins: [], + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + let expectedPath = path.resolve(__dirname, './oxide-svelte.test.css') + let expected = fs.readFileSync(expectedPath, 'utf8') + + expect(result.css).toMatchFormattedCss(expected) + }) + } +) diff --git a/tests/parseObjectStyles.test.js b/tests/parseObjectStyles.test.js index 06aa06c86..f44bb8ec8 100644 --- a/tests/parseObjectStyles.test.js +++ b/tests/parseObjectStyles.test.js @@ -1,5 +1,10 @@ import parseObjectStyles from '../src/util/parseObjectStyles' import postcss from 'postcss' +import { env } from '../src/lib/sharedState' + +// The skipped tests for OXIDE are testing nested structures. In the oxide version we make use of +// nesting, and parseObjectStyles keeps everything nested insted of unnesting everything. +let t = env.OXIDE ? test.skip : test function css(nodes) { return postcss.root({ nodes }).toString() @@ -49,16 +54,16 @@ test('it parses multiple class definitions', () => { `) }) -test('it parses nested pseudo-selectors', () => { +t('it parses nested pseudo-selectors', () => { const result = parseObjectStyles({ '.foo': { backgroundColor: 'red', color: 'white', padding: '1rem', - ':hover': { + '&:hover': { backgroundColor: 'orange', }, - ':focus': { + '&:focus': { backgroundColor: 'blue', }, }, @@ -97,7 +102,7 @@ test('it parses top-level media queries', () => { `) }) -test('it parses nested media queries', () => { +t('it parses nested media queries', () => { const result = parseObjectStyles({ '.foo': { backgroundColor: 'red', @@ -123,7 +128,7 @@ test('it parses nested media queries', () => { `) }) -test('it bubbles nested screen rules', () => { +t('it bubbles nested screen rules', () => { const result = parseObjectStyles({ '.foo': { backgroundColor: 'red', @@ -149,13 +154,13 @@ test('it bubbles nested screen rules', () => { `) }) -test('it parses pseudo-selectors in nested media queries', () => { +t('it parses pseudo-selectors in nested media queries', () => { const result = parseObjectStyles({ '.foo': { backgroundColor: 'red', color: 'white', padding: '1rem', - ':hover': { + '&:hover': { '@media (min-width: 200px)': { backgroundColor: 'orange', }, @@ -177,7 +182,7 @@ test('it parses pseudo-selectors in nested media queries', () => { `) }) -test('it parses descendant selectors', () => { +t('it parses descendant selectors', () => { const result = parseObjectStyles({ '.foo': { backgroundColor: 'red', @@ -201,7 +206,7 @@ test('it parses descendant selectors', () => { `) }) -test('it parses nested multi-class selectors', () => { +t('it parses nested multi-class selectors', () => { const result = parseObjectStyles({ '.foo': { backgroundColor: 'red', @@ -225,7 +230,7 @@ test('it parses nested multi-class selectors', () => { `) }) -test('it parses nested multi-class selectors in media queries', () => { +t('it parses nested multi-class selectors in media queries', () => { const result = parseObjectStyles({ '.foo': { backgroundColor: 'red', @@ -253,7 +258,7 @@ test('it parses nested multi-class selectors in media queries', () => { `) }) -test('it strips empty selectors when nesting', () => { +t('it strips empty selectors when nesting', () => { const result = parseObjectStyles({ '.foo': { '.bar': { diff --git a/tests/preflight.test.js b/tests/preflight.test.js index 7ba5d3f9f..8997033a3 100644 --- a/tests/preflight.test.js +++ b/tests/preflight.test.js @@ -1,4 +1,5 @@ import { run, html, css } from './util/run' +import { env } from '../src/lib/sharedState' it('preflight has a correct border color fallback', () => { let config = { @@ -16,6 +17,12 @@ it('preflight has a correct border color fallback', () => { ` return run(input, config).then((result) => { - expect(result.css).toContain(`border-color: currentColor;`) + if (env.OXIDE) { + // Lightning CSS optimizes this to just `border-color: 0 solid;` based on the browserslist + // value. + expect(result.css).toContain(`border: 0 solid;`) + } else { + expect(result.css).toContain(`border-color: currentColor;`) + } }) }) diff --git a/tests/syntax-lit-html.test.js b/tests/syntax-lit-html.test.js index 1a780d136..ce75f0283 100644 --- a/tests/syntax-lit-html.test.js +++ b/tests/syntax-lit-html.test.js @@ -15,7 +15,7 @@ test('it detects classes in lit-html templates', () => { } return run('@tailwind utilities', config).then((result) => { - expect(result.css).toMatchCss(css` + expect(result.css).toMatchFormattedCss(css` .rounded { border-radius: 0.25rem; } diff --git a/tests/variants.test.js b/tests/variants.test.js index 7ed5dc5c2..db0bb4dba 100644 --- a/tests/variants.test.js +++ b/tests/variants.test.js @@ -245,14 +245,16 @@ describe('custom advanced variants', () => { }) }) -test('stacked peer variants', async () => { +test.only('stacked peer variants', async () => { let config = { - content: [{ raw: 'peer-disabled:peer-focus:peer-hover:border-blue-500' }], + content: [ + { raw: 'peer-disabled:peer-focus:peer-hover:border-blue-500' }, + // { raw: 'group-focus:group-hover:peer-disabled:peer-focus:peer-hover:border-blue-500' }, + ], corePlugins: { preflight: false }, } let input = css` - @tailwind base; @tailwind components; @tailwind utilities; ` @@ -265,7 +267,7 @@ test('stacked peer variants', async () => { ` let result = await run(input, config) - expect(result.css).toIncludeCss(expected) + expect(result.css).toMatchFormattedCss(expected) }) it('should properly handle keyframes with multiple variants', async () => { @@ -1115,21 +1117,27 @@ test('arbitrary variant selectors should not re-order scrollbar pseudo classes', .\[\&\:\:-webkit-resizer\:hover\]\:underline::-webkit-resizer:hover { text-decoration-line: underline; } + .\[\&\:\:-webkit-scrollbar-button\:hover\]\:underline::-webkit-scrollbar-button:hover { text-decoration-line: underline; } + .\[\&\:\:-webkit-scrollbar-corner\:hover\]\:underline::-webkit-scrollbar-corner:hover { text-decoration-line: underline; } + .\[\&\:\:-webkit-scrollbar-thumb\:hover\]\:underline::-webkit-scrollbar-thumb:hover { text-decoration-line: underline; } + .\[\&\:\:-webkit-scrollbar-track-piece\:hover\]\:underline::-webkit-scrollbar-track-piece:hover { text-decoration-line: underline; } + .\[\&\:\:-webkit-scrollbar-track\:hover\]\:underline::-webkit-scrollbar-track:hover { text-decoration-line: underline; } + .\[\&\:\:-webkit-scrollbar\:hover\]\:underline::-webkit-scrollbar:hover { text-decoration-line: underline; }