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!
This commit is contained in:
Robin Malfait 2022-12-08 15:07:06 +01:00
parent d378bc3b5c
commit a1246de6a5
No known key found for this signature in database
GPG key ID: 92F53D68B9041AFE
24 changed files with 3444 additions and 1891 deletions

View file

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

View file

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

View file

@ -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,
])
}
}
})(),

View file

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

View file

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

View file

@ -48,6 +48,7 @@ export async function lightningcss(shouldMinify: boolean, result: Result) {
targets: css.browserslistToTargets(browserslist(packageJson.browserslist)),
drafts: {
nesting: true,
customMedia: true,
},
})

View file

@ -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<string, [RuleOffset, Rule][]>
) {
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<string, [RuleOffset, Rule][]>()
// 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)
})
}
}

View file

@ -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('[', '&lsqb;')
.replace(']', '&rsqb;')}\` 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(']')
}

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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\)\] {

View file

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

View file

@ -306,7 +306,7 @@ it('can parse box shadows with variables', () => {
content: [{ raw: html`<div class="shadow-lg"></div>` }],
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);
}
`)
})

View file

@ -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`<div class="nested"></div>` }],
}
// 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`<div class="nested"></div>` }],
}
`
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`<div class="underline"></div>` }],
}
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`<div class="text-center"></div>` }],
}
let input = css`
.namespace {
@tailwind utilities;
it('should not warn when we detect nested css inside css @layer rules', () => {
let config = {
content: [{ raw: html`<div class="underline"></div>` }],
}
`
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`<div class="nested"></div>` }],
}
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`<div class="nested other"></div>` }],
}
it('should warn when we detect namespaced @tailwind at rules', () => {
let config = {
content: [{ raw: html`<div class="text-center"></div>` }],
}
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`<div class="nested"></div>` }],
}
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`<div class="nested other"></div>` }],
}
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)
})
})
})

View file

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

View file

@ -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')],

484
tests/oxide.apply.test.js Normal file
View file

@ -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`<button class="btn">My Button</button>` }],
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`<button class="btn">My Button</button>` }],
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`<button class="btn">My Button</button>` }],
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`<button class="btn">My Button</button>` }],
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`<button class="btn">My Button</button>` }],
}
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`<button class="btn">My Button</button>` }],
}
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`<button class="btn">My Button</button>` }],
}
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`<button class="btn">My Button</button>` }],
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`<button class="btn">My Button</button>` }],
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`<button class="btn">My Button</button>` }],
}
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`<button class="btn">My Button</button>` }],
}
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`<button class="btn">My Button</button>` }],
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`<button class="btn">My Button</button>` }],
}
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`<button class="btn">My Button</button>` }],
}
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`<button class="btn btn-2">My Button</button>` }],
}
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;
}
`)
})
})
})

View file

@ -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`<div class="multi:text-left"></div>` }],
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)
})
}
)

View file

@ -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': {

View file

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

View file

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

View file

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