Skip to content

Commit

Permalink
Arbitrary variants (#8299)
Browse files Browse the repository at this point in the history
* 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 <jordan@cryptica.me>
Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
  • Loading branch information
3 people committed May 8, 2022
1 parent cea3ccf commit be51739
Show file tree
Hide file tree
Showing 9 changed files with 525 additions and 88 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `backdrop` variant ([#7924](https://github.com/tailwindlabs/tailwindcss/pull/7924))
- Add `grid-flow-dense` utility ([#8193](https://github.com/tailwindlabs/tailwindcss/pull/8193))
- Add `mix-blend-plus-lighter` utility ([#8288](https://github.com/tailwindlabs/tailwindcss/pull/8288))
- Add arbitrary variants ([#8299](https://github.com/tailwindlabs/tailwindcss/pull/8299))

## [3.0.24] - 2022-04-12

Expand Down
37 changes: 23 additions & 14 deletions src/lib/defaultExtractor.js
@@ -1,25 +1,34 @@
import * as regex from './regex'

let patterns = Array.from(buildRegExps())

/**
* @param {string} content
*/
export function defaultExtractor(content) {
/** @type {(string|string)[]} */
let results = []
export function defaultExtractor(context) {
let patterns = Array.from(buildRegExps(context))

/**
* @param {string} content
*/
return (content) => {
/** @type {(string|string)[]} */
let results = []

for (let pattern of patterns) {
results.push(...(content.match(pattern) ?? []))
}

for (let pattern of patterns) {
results.push(...(content.match(pattern) ?? []))
return results.filter((v) => v !== undefined).map(clipAtBalancedParens)
}

return results.filter((v) => v !== undefined).map(clipAtBalancedParens)
}

function* buildRegExps() {
function* buildRegExps(context) {
let separator = context.tailwindConfig.separator

yield regex.pattern([
// Variants
/((?=([^\s"'\\\[]+:))\2)?/,
'((?=((',
regex.any(
[regex.pattern([/\[[^\s"'\\]+\]/, separator]), regex.pattern([/[^\s"'\[\\]+/, separator])],
true
),
')+))\\2)?',

// Important (optional)
/!?/,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/expandTailwindAtRules.js
Expand Up @@ -24,7 +24,7 @@ function getExtractor(context, fileExtension) {
extractors[fileExtension] ||
extractors.DEFAULT ||
builtInExtractors[fileExtension] ||
builtInExtractors.DEFAULT
builtInExtractors.DEFAULT(context)
)
}

Expand Down
15 changes: 14 additions & 1 deletion src/lib/generateRules.js
Expand Up @@ -9,7 +9,9 @@ import * as sharedState from './sharedState'
import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
import { asClass } from '../util/nameClass'
import { normalize } from '../util/dataTypes'
import { parseVariant } from './setupContextUtils'
import isValidArbitraryValue from '../util/isValidArbitraryValue'
import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js'

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

// Register arbitrary variants
if (isArbitraryValue(variant) && !context.variantMap.has(variant)) {
let selector = normalize(variant.slice(1, -1))

let fn = parseVariant(selector)

let sort = Array.from(context.variantOrder.values()).pop() << 1n
context.variantMap.set(variant, [[sort, fn]])
context.variantOrder.set(variant, sort)
}

if (context.variantMap.has(variant)) {
let variantFunctionTuples = context.variantMap.get(variant)
let result = []
Expand Down Expand Up @@ -407,7 +420,7 @@ function splitWithSeparator(input, separator) {
return [sharedState.NOT_ON_DEMAND]
}

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

function* recordCandidates(matches, classCandidate) {
Expand Down
46 changes: 25 additions & 21 deletions src/lib/setupContextUtils.js
Expand Up @@ -170,6 +170,30 @@ function withIdentifiers(styles) {
})
}

export function parseVariant(variant) {
variant = variant
.replace(/\n+/g, '')
.replace(/\s{1,}/g, ' ')
.trim()

let fns = parseVariantFormatString(variant)
.map((str) => {
if (!str.startsWith('@')) {
return ({ format }) => format(str)
}

let [, name, params] = /@(.*?)( .+|[({].*)/g.exec(str)
return ({ wrap }) => wrap(postcss.atRule({ name, params: params.trim() }))
})
.reverse()

return (api) => {
for (let fn of fns) {
fn(api)
}
}
}

function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) {
function getConfigValue(path, defaultValue) {
return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
Expand Down Expand Up @@ -201,27 +225,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
}
}

variantFunction = variantFunction
.replace(/\n+/g, '')
.replace(/\s{1,}/g, ' ')
.trim()

let fns = parseVariantFormatString(variantFunction)
.map((str) => {
if (!str.startsWith('@')) {
return ({ format }) => format(str)
}

let [, name, params] = /@(.*?) (.*)/g.exec(str)
return ({ wrap }) => wrap(postcss.atRule({ name, params }))
})
.reverse()

return (api) => {
for (let fn of fns) {
fn(api)
}
}
return parseVariant(variantFunction)
})

insertInto(variantList, variantName, options)
Expand Down
53 changes: 3 additions & 50 deletions src/util/parseBoxShadowValue.js
@@ -1,58 +1,11 @@
import { splitAtTopLevelOnly } from './splitAtTopLevelOnly'

let KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset'])
let SPACE = /\ +(?![^(]*\))/g // Similar to the one above, but with spaces instead.
let LENGTH = /^-?(\d+|\.\d+)(.*?)$/g

let SPECIALS = /[(),]/g

/**
* This splits a string on top-level commas.
*
* Regex doesn't support recursion (at least not the JS-flavored version).
* So we have to use a tiny state machine to keep track of paren vs comma
* placement. Before we'd only exclude commas from the inner-most nested
* set of parens rather than any commas that were not contained in parens
* at all which is the intended behavior here.
*
* Expected behavior:
* var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)
* ─┬─ ┬ ┬ ┬
* x x x ╰──────── Split because top-level
* ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens
*
* @param {string} input
*/
function* splitByTopLevelCommas(input) {
SPECIALS.lastIndex = -1

let depth = 0
let lastIndex = 0
let found = false

// Find all parens & commas
// And only split on commas if they're top-level
for (let match of input.matchAll(SPECIALS)) {
if (match[0] === '(') depth++
if (match[0] === ')') depth--
if (match[0] === ',' && depth === 0) {
found = true

yield input.substring(lastIndex, match.index)
lastIndex = match.index + match[0].length
}
}

// Provide the last segment of the string if available
// Otherwise the whole string since no commas were found
// This mirrors the behavior of string.split()
if (found) {
yield input.substring(lastIndex)
} else {
yield input
}
}

export function parseBoxShadowValue(input) {
let shadows = Array.from(splitByTopLevelCommas(input))
let shadows = Array.from(splitAtTopLevelOnly(input, ','))
return shadows.map((shadow) => {
let value = shadow.trim()
let result = { raw: value }
Expand Down
71 changes: 71 additions & 0 deletions src/util/splitAtTopLevelOnly.js
@@ -0,0 +1,71 @@
import * as regex from '../lib/regex'

/**
* This splits a string on a top-level character.
*
* Regex doesn't support recursion (at least not the JS-flavored version).
* So we have to use a tiny state machine to keep track of paren placement.
*
* Expected behavior using commas:
* var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)
* ─┬─ ┬ ┬ ┬
* x x x ╰──────── Split because top-level
* ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens
*
* @param {string} input
* @param {string} separator
*/
export function* splitAtTopLevelOnly(input, separator) {
let SPECIALS = new RegExp(`[(){}\\[\\]${regex.escape(separator)}]`, 'g')

let depth = 0
let lastIndex = 0
let found = false
let separatorIndex = 0
let separatorStart = 0
let separatorLength = separator.length

// Find all paren-like things & character
// And only split on commas if they're top-level
for (let match of input.matchAll(SPECIALS)) {
let matchesSeparator = match[0] === separator[separatorIndex]
let atEndOfSeparator = separatorIndex === separatorLength - 1
let matchesFullSeparator = matchesSeparator && atEndOfSeparator

if (match[0] === '(') depth++
if (match[0] === ')') depth--
if (match[0] === '[') depth++
if (match[0] === ']') depth--
if (match[0] === '{') depth++
if (match[0] === '}') depth--

if (matchesSeparator && depth === 0) {
if (separatorStart === 0) {
separatorStart = match.index
}

separatorIndex++
}

if (matchesFullSeparator && depth === 0) {
found = true

yield input.substring(lastIndex, separatorStart)
lastIndex = separatorStart + separatorLength
}

if (separatorIndex === separatorLength) {
separatorIndex = 0
separatorStart = 0
}
}

// Provide the last segment of the string if available
// Otherwise the whole string since no `char`s were found
// This mirrors the behavior of string.split()
if (found) {
yield input.substring(lastIndex)
} else {
yield input
}
}

0 comments on commit be51739

Please sign in to comment.