Skip to content

Commit be51739

Browse files
RobinMalfaitthecrypticaceadamwathan
authored
Arbitrary variants (#8299)
* register arbitrary variants With the new `addVariant` API, we have a beautiful way of creating new variants. You can use it as: ```js addVariant('children', '& > *') ``` Now you can use the `children:` variant. The API uses a `&` as a reference for the candidate, which means that: ```html children:pl-4 ``` Will result in: ```css .children\:pl-4 > * { .. } ``` Notice that the `&` was replaced by `.children\:pl-4`. We can leverage this API to implement arbitrary variants, this means that you can write those `&>*` (Notice that we don't have spaces) inside a variant directly. An example of this can be: ```html <ul class="[&>*]:underline"> <li>A</li> <li>B</li> <li>C</li> </ul> ``` Which generates the following css: ```css .\[\&\>\*\]\:underline > * { text-decoration-line: underline; } ``` Now all the children of the `ul` will have an `underline`. The selector itself is a bit crazy since it contains the candidate which is the selector itself, it is just escaped. * add tests for arbitrary variants This still requires some work to the `defaultExtractor` to make sure it all works with existing code. * update changelog * Fix candidate detection for arbitrary variants * Refactor * Add support for at rules * Add test for attribute selectors * Fix test * Add attribute selector support * Split top-level comma parsing into a generalized splitting routine We can now split on any character at the top level with any nesting. We don’t balance brackets directly here but this is probably “enough” * Split variants by separator at the top-level only This means that the separator has to be ouside of balanced brackets * Fix extraction when using custom variant separators * Support custom separators when top-level splitting variants * Add a second multi-character separator test * Split tests for at-rule and at-rule with selector changes * Add nested at-rule tests * Fix space-less at-rule parsing in addVariant * Add test for using with `@apply` Co-authored-by: Jordan Pittman <[email protected]> Co-authored-by: Adam Wathan <[email protected]>
1 parent cea3ccf commit be51739

9 files changed

+525
-88
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939
- Add `backdrop` variant ([#7924](https://github.com/tailwindlabs/tailwindcss/pull/7924))
4040
- Add `grid-flow-dense` utility ([#8193](https://github.com/tailwindlabs/tailwindcss/pull/8193))
4141
- Add `mix-blend-plus-lighter` utility ([#8288](https://github.com/tailwindlabs/tailwindcss/pull/8288))
42+
- Add arbitrary variants ([#8299](https://github.com/tailwindlabs/tailwindcss/pull/8299))
4243

4344
## [3.0.24] - 2022-04-12
4445

src/lib/defaultExtractor.js

+23-14
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
11
import * as regex from './regex'
22

3-
let patterns = Array.from(buildRegExps())
4-
5-
/**
6-
* @param {string} content
7-
*/
8-
export function defaultExtractor(content) {
9-
/** @type {(string|string)[]} */
10-
let results = []
3+
export function defaultExtractor(context) {
4+
let patterns = Array.from(buildRegExps(context))
5+
6+
/**
7+
* @param {string} content
8+
*/
9+
return (content) => {
10+
/** @type {(string|string)[]} */
11+
let results = []
12+
13+
for (let pattern of patterns) {
14+
results.push(...(content.match(pattern) ?? []))
15+
}
1116

12-
for (let pattern of patterns) {
13-
results.push(...(content.match(pattern) ?? []))
17+
return results.filter((v) => v !== undefined).map(clipAtBalancedParens)
1418
}
15-
16-
return results.filter((v) => v !== undefined).map(clipAtBalancedParens)
1719
}
1820

19-
function* buildRegExps() {
21+
function* buildRegExps(context) {
22+
let separator = context.tailwindConfig.separator
23+
2024
yield regex.pattern([
2125
// Variants
22-
/((?=([^\s"'\\\[]+:))\2)?/,
26+
'((?=((',
27+
regex.any(
28+
[regex.pattern([/\[[^\s"'\\]+\]/, separator]), regex.pattern([/[^\s"'\[\\]+/, separator])],
29+
true
30+
),
31+
')+))\\2)?',
2332

2433
// Important (optional)
2534
/!?/,

src/lib/expandTailwindAtRules.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function getExtractor(context, fileExtension) {
2424
extractors[fileExtension] ||
2525
extractors.DEFAULT ||
2626
builtInExtractors[fileExtension] ||
27-
builtInExtractors.DEFAULT
27+
builtInExtractors.DEFAULT(context)
2828
)
2929
}
3030

src/lib/generateRules.js

+14-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import * as sharedState from './sharedState'
99
import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
1010
import { asClass } from '../util/nameClass'
1111
import { normalize } from '../util/dataTypes'
12+
import { parseVariant } from './setupContextUtils'
1213
import isValidArbitraryValue from '../util/isValidArbitraryValue'
14+
import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js'
1315

1416
let classNameParser = selectorParser((selectors) => {
1517
return selectors.first.filter(({ type }) => type === 'class').pop().value
@@ -125,6 +127,17 @@ function applyVariant(variant, matches, context) {
125127
return matches
126128
}
127129

130+
// Register arbitrary variants
131+
if (isArbitraryValue(variant) && !context.variantMap.has(variant)) {
132+
let selector = normalize(variant.slice(1, -1))
133+
134+
let fn = parseVariant(selector)
135+
136+
let sort = Array.from(context.variantOrder.values()).pop() << 1n
137+
context.variantMap.set(variant, [[sort, fn]])
138+
context.variantOrder.set(variant, sort)
139+
}
140+
128141
if (context.variantMap.has(variant)) {
129142
let variantFunctionTuples = context.variantMap.get(variant)
130143
let result = []
@@ -407,7 +420,7 @@ function splitWithSeparator(input, separator) {
407420
return [sharedState.NOT_ON_DEMAND]
408421
}
409422

410-
return input.split(new RegExp(`\\${separator}(?![^[]*\\])`, 'g'))
423+
return Array.from(splitAtTopLevelOnly(input, separator))
411424
}
412425

413426
function* recordCandidates(matches, classCandidate) {

src/lib/setupContextUtils.js

+25-21
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,30 @@ function withIdentifiers(styles) {
170170
})
171171
}
172172

173+
export function parseVariant(variant) {
174+
variant = variant
175+
.replace(/\n+/g, '')
176+
.replace(/\s{1,}/g, ' ')
177+
.trim()
178+
179+
let fns = parseVariantFormatString(variant)
180+
.map((str) => {
181+
if (!str.startsWith('@')) {
182+
return ({ format }) => format(str)
183+
}
184+
185+
let [, name, params] = /@(.*?)( .+|[({].*)/g.exec(str)
186+
return ({ wrap }) => wrap(postcss.atRule({ name, params: params.trim() }))
187+
})
188+
.reverse()
189+
190+
return (api) => {
191+
for (let fn of fns) {
192+
fn(api)
193+
}
194+
}
195+
}
196+
173197
function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) {
174198
function getConfigValue(path, defaultValue) {
175199
return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
@@ -201,27 +225,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
201225
}
202226
}
203227

204-
variantFunction = variantFunction
205-
.replace(/\n+/g, '')
206-
.replace(/\s{1,}/g, ' ')
207-
.trim()
208-
209-
let fns = parseVariantFormatString(variantFunction)
210-
.map((str) => {
211-
if (!str.startsWith('@')) {
212-
return ({ format }) => format(str)
213-
}
214-
215-
let [, name, params] = /@(.*?) (.*)/g.exec(str)
216-
return ({ wrap }) => wrap(postcss.atRule({ name, params }))
217-
})
218-
.reverse()
219-
220-
return (api) => {
221-
for (let fn of fns) {
222-
fn(api)
223-
}
224-
}
228+
return parseVariant(variantFunction)
225229
})
226230

227231
insertInto(variantList, variantName, options)

src/util/parseBoxShadowValue.js

+3-50
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,11 @@
1+
import { splitAtTopLevelOnly } from './splitAtTopLevelOnly'
2+
13
let KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset'])
24
let SPACE = /\ +(?![^(]*\))/g // Similar to the one above, but with spaces instead.
35
let LENGTH = /^-?(\d+|\.\d+)(.*?)$/g
46

5-
let SPECIALS = /[(),]/g
6-
7-
/**
8-
* This splits a string on top-level commas.
9-
*
10-
* Regex doesn't support recursion (at least not the JS-flavored version).
11-
* So we have to use a tiny state machine to keep track of paren vs comma
12-
* placement. Before we'd only exclude commas from the inner-most nested
13-
* set of parens rather than any commas that were not contained in parens
14-
* at all which is the intended behavior here.
15-
*
16-
* Expected behavior:
17-
* var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)
18-
* ─┬─ ┬ ┬ ┬
19-
* x x x ╰──────── Split because top-level
20-
* ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens
21-
*
22-
* @param {string} input
23-
*/
24-
function* splitByTopLevelCommas(input) {
25-
SPECIALS.lastIndex = -1
26-
27-
let depth = 0
28-
let lastIndex = 0
29-
let found = false
30-
31-
// Find all parens & commas
32-
// And only split on commas if they're top-level
33-
for (let match of input.matchAll(SPECIALS)) {
34-
if (match[0] === '(') depth++
35-
if (match[0] === ')') depth--
36-
if (match[0] === ',' && depth === 0) {
37-
found = true
38-
39-
yield input.substring(lastIndex, match.index)
40-
lastIndex = match.index + match[0].length
41-
}
42-
}
43-
44-
// Provide the last segment of the string if available
45-
// Otherwise the whole string since no commas were found
46-
// This mirrors the behavior of string.split()
47-
if (found) {
48-
yield input.substring(lastIndex)
49-
} else {
50-
yield input
51-
}
52-
}
53-
547
export function parseBoxShadowValue(input) {
55-
let shadows = Array.from(splitByTopLevelCommas(input))
8+
let shadows = Array.from(splitAtTopLevelOnly(input, ','))
569
return shadows.map((shadow) => {
5710
let value = shadow.trim()
5811
let result = { raw: value }

src/util/splitAtTopLevelOnly.js

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as regex from '../lib/regex'
2+
3+
/**
4+
* This splits a string on a top-level character.
5+
*
6+
* Regex doesn't support recursion (at least not the JS-flavored version).
7+
* So we have to use a tiny state machine to keep track of paren placement.
8+
*
9+
* Expected behavior using commas:
10+
* var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)
11+
* ─┬─ ┬ ┬ ┬
12+
* x x x ╰──────── Split because top-level
13+
* ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens
14+
*
15+
* @param {string} input
16+
* @param {string} separator
17+
*/
18+
export function* splitAtTopLevelOnly(input, separator) {
19+
let SPECIALS = new RegExp(`[(){}\\[\\]${regex.escape(separator)}]`, 'g')
20+
21+
let depth = 0
22+
let lastIndex = 0
23+
let found = false
24+
let separatorIndex = 0
25+
let separatorStart = 0
26+
let separatorLength = separator.length
27+
28+
// Find all paren-like things & character
29+
// And only split on commas if they're top-level
30+
for (let match of input.matchAll(SPECIALS)) {
31+
let matchesSeparator = match[0] === separator[separatorIndex]
32+
let atEndOfSeparator = separatorIndex === separatorLength - 1
33+
let matchesFullSeparator = matchesSeparator && atEndOfSeparator
34+
35+
if (match[0] === '(') depth++
36+
if (match[0] === ')') depth--
37+
if (match[0] === '[') depth++
38+
if (match[0] === ']') depth--
39+
if (match[0] === '{') depth++
40+
if (match[0] === '}') depth--
41+
42+
if (matchesSeparator && depth === 0) {
43+
if (separatorStart === 0) {
44+
separatorStart = match.index
45+
}
46+
47+
separatorIndex++
48+
}
49+
50+
if (matchesFullSeparator && depth === 0) {
51+
found = true
52+
53+
yield input.substring(lastIndex, separatorStart)
54+
lastIndex = separatorStart + separatorLength
55+
}
56+
57+
if (separatorIndex === separatorLength) {
58+
separatorIndex = 0
59+
separatorStart = 0
60+
}
61+
}
62+
63+
// Provide the last segment of the string if available
64+
// Otherwise the whole string since no `char`s were found
65+
// This mirrors the behavior of string.split()
66+
if (found) {
67+
yield input.substring(lastIndex)
68+
} else {
69+
yield input
70+
}
71+
}

0 commit comments

Comments
 (0)