Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Arbitrary variants #8299

Merged
merged 18 commits into from May 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
}
}