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:
parent
d378bc3b5c
commit
a1246de6a5
24 changed files with 3444 additions and 1891 deletions
|
|
@ -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 }
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
})(),
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export async function lightningcss(shouldMinify: boolean, result: Result) {
|
|||
targets: css.browserslistToTargets(browserslist(packageJson.browserslist)),
|
||||
drafts: {
|
||||
nesting: true,
|
||||
customMedia: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
126
src/oxide/core/expand-apply-at-rules.ts
Normal file
126
src/oxide/core/expand-apply-at-rules.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
829
src/oxide/core/resolve-matches.ts
Normal file
829
src/oxide/core/resolve-matches.ts
Normal 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('[', '[')
|
||||
.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(']')
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
2911
tests/apply.test.js
2911
tests/apply.test.js
File diff suppressed because it is too large
Load diff
|
|
@ -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\)\] {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
484
tests/oxide.apply.test.js
Normal 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;
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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;`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue