Improve data type analyses for arbitrary values (#9320)

* improve split logic by delimiter

The original RegEx did mostly what we want, the idea is that we wanted
to split by a `,` but one that was not within `()`. This is useful when
you define multiple background colors for example:
```html
<div class="bg-[rgb(0,0,0),rgb(255,255,255)]"></div>
```

In this case splitting by the regex would result in the proper result:
```js
let result = [
  'rgb(0,0,0)',
  'rgb(255,255,255)'
]
```

Visually, you can think of it like:
```
    ┌─[./example.html]
    │
∙ 1 │   <div class="bg-[rgb(0,0,0),rgb(255,255,255)]"></div>
    ·                       ──┬── ┬    ─────┬─────
    ·                         │   │         ╰─────── Guarded by parens
    ·                         │   ╰───────────────── We will split here
    ·                         ╰───────────────────── Guarded by parens
    │
    └─
```

We properly split by `,` not inside a `()`. However, this RegEx fails
the moment you have deeply nested RegEx values.

Visually, this is what's happening:
```
    ┌─[./example.html]
    │
∙ 1 │   <div class="bg-[rgba(0,0,0,var(--alpha))]"></div>
    ·                         ┬ ┬ ┬
    ·                         ╰─┴─┴── We accidentally split here
    │
    └─
```
This is because on the right of the `,`, the first paren is an opening
paren `(` instead of a closing one `)`.

I'm not 100% sure how we can improve the RegEx to handle that case as
well, instead I wrote a small `splitBy` function that allows you to
split the string by a character (just like you could do before) but
ignores the ones inside the given exceptions. This keeps track of a
stack to know whether we are within parens or not.

Visually, the fix looks like this:
```
    ┌─[./example.html]
    │
∙ 1 │   <div class="bg-[rgba(0,0,0,var(--alpha)),rgb(255,255,255,var(--alpha))]"></div>
    ·                         ┬ ┬ ┬             ┬       ┬   ┬   ┬
    ·                         │ │ │             │       ╰───┴───┴── Guarded by parens
    ·                         │ │ │             ╰────────────────── We will split here
    ·                         ╰─┴─┴──────────────────────────────── Guarded by parens
    │
    └─
```

* use already existing `splitAtTopLevelOnly` function

* add faster implemetation for `splitAtTopLevelOnly`

However, the faster version can't handle separators with multiple
characters right now. So instead of using buggy code or only using the
"slower" code, we've added a fast path where we use the faster code
wherever we can.

* use `splitAtTopLevelOnly` directly

* make split go brrrrrrr

* update changelog

* remove unncessary array.from call

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Robin Malfait 2022-09-14 14:08:56 +02:00 • committed by GitHub
parent 3e6b8ac6e5
commit 527031d5f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 35 additions and 58 deletions

View file

@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Don't mutate shared config objects ([#9294](https://github.com/tailwindlabs/tailwindcss/pull/9294))
- Fix ordering of parallel variants ([#9282](https://github.com/tailwindlabs/tailwindcss/pull/9282))
- Handle variants in utility selectors using `:where()` and `:has()` ([#9309](https://github.com/tailwindlabs/tailwindcss/pull/9309))
- Improve data type analyses for arbitrary values ([#9320](https://github.com/tailwindlabs/tailwindcss/pull/9320))
## [3.1.8] - 2022-08-05

View file

@ -461,7 +461,7 @@ function splitWithSeparator(input, separator) {
return [sharedState.NOT_ON_DEMAND]
}
return Array.from(splitAtTopLevelOnly(input, separator))
return splitAtTopLevelOnly(input, separator)
}
function* recordCandidates(matches, classCandidate) {

View file

@ -1,13 +1,11 @@
import { parseColor } from './color'
import { parseBoxShadowValue } from './parseBoxShadowValue'
import { splitAtTopLevelOnly } from './splitAtTopLevelOnly'
let cssFunctions = ['min', 'max', 'clamp', 'calc']
// Ref: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Types
let COMMA = /,(?![^(]*\))/g // Comma separator that is not located between brackets. E.g.: `cubiz-bezier(a, b, c)` these don't count.
let UNDERSCORE = /_(?![^(]*\))/g // Underscore separator that is not located between brackets. E.g.: `rgba(255,_255,_255)_black` these don't count.
// This is not a data type, but rather a function that can normalize the
// correct values.
export function normalize(value, isRoot = true) {
@ -61,7 +59,7 @@ export function number(value) {
}
export function percentage(value) {
return value.split(UNDERSCORE).every((part) => {
return splitAtTopLevelOnly(value, '_').every((part) => {
return /%$/g.test(part) || cssFunctions.some((fn) => new RegExp(`^${fn}\\(.+?%`).test(part))
})
}
@ -86,7 +84,7 @@ let lengthUnits = [
]
let lengthUnitsPattern = `(?:${lengthUnits.join('|')})`
export function length(value) {
return value.split(UNDERSCORE).every((part) => {
return splitAtTopLevelOnly(value, '_').every((part) => {
return (
part === '0' ||
new RegExp(`${lengthUnitsPattern}$`).test(part) ||
@ -115,7 +113,7 @@ export function shadow(value) {
export function color(value) {
let colors = 0
let result = value.split(UNDERSCORE).every((part) => {
let result = splitAtTopLevelOnly(value, '_').every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true
@ -130,7 +128,7 @@ export function color(value) {
export function image(value) {
let images = 0
let result = value.split(COMMA).every((part) => {
let result = splitAtTopLevelOnly(value, ',').every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true
@ -171,7 +169,7 @@ export function gradient(value) {
let validPositions = new Set(['center', 'top', 'right', 'bottom', 'left'])
export function position(value) {
let positions = 0
let result = value.split(UNDERSCORE).every((part) => {
let result = splitAtTopLevelOnly(value, '_').every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true
@ -189,7 +187,7 @@ export function position(value) {
export function familyName(value) {
let fonts = 0
let result = value.split(COMMA).every((part) => {
let result = splitAtTopLevelOnly(value, ',').every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true

View file

@ -5,7 +5,7 @@ let SPACE = /\ +(?![^(]*\))/g // Similar to the one above, but with spaces inste
let LENGTH = /^-?(\d+|\.\d+)(.*?)$/g
export function parseBoxShadowValue(input) {
let shadows = Array.from(splitAtTopLevelOnly(input, ','))
let shadows = splitAtTopLevelOnly(input, ',')
return shadows.map((shadow) => {
let value = shadow.trim()
let result = { raw: value }

View file

@ -1,5 +1,3 @@
import * as regex from '../lib/regex'
/**
* This splits a string on a top-level character.
*
@ -15,57 +13,33 @@ import * as regex from '../lib/regex'
* @param {string} input
* @param {string} separator
*/
export function* splitAtTopLevelOnly(input, separator) {
let SPECIALS = new RegExp(`[(){}\\[\\]${regex.escape(separator)}]`, 'g')
export function splitAtTopLevelOnly(input, separator) {
let stack = []
let parts = []
let lastPos = 0
let depth = 0
let lastIndex = 0
let found = false
let separatorIndex = 0
let separatorStart = 0
let separatorLength = separator.length
for (let idx = 0; idx < input.length; idx++) {
let char = input[idx]
// 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
if (stack.length === 0 && char === separator[0]) {
if (separator.length === 1 || input.slice(idx, idx + separator.length) === separator) {
parts.push(input.slice(lastPos, idx))
lastPos = idx + separator.length
}
separatorIndex++
}
if (matchesFullSeparator && depth === 0) {
found = true
yield input.substring(lastIndex, separatorStart)
lastIndex = separatorStart + separatorLength
}
if (separatorIndex === separatorLength) {
separatorIndex = 0
separatorStart = 0
if (char === '(' || char === '[' || char === '{') {
stack.push(char)
} else if (
(char === ')' && stack[stack.length - 1] === '(') ||
(char === ']' && stack[stack.length - 1] === '[') ||
(char === '}' && stack[stack.length - 1] === '{')
) {
stack.pop()
}
}
// 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
}
parts.push(input.slice(lastPos))
return parts
}

View file

@ -661,6 +661,9 @@
.bg-opacity-\[var\(--value\)\] {
--tw-bg-opacity: var(--value);
}
.bg-\[linear-gradient\(to_left\2c rgb\(var\(--green\)\)\2c blue\)\] {
background-image: linear-gradient(to left, rgb(var(--green)), blue);
}
.bg-\[url\(\'\/path-to-image\.png\'\)\] {
background-image: url('/path-to-image.png');
}

View file

@ -220,6 +220,7 @@
<div class="bg-[#0f0_var(--value)]"></div>
<div class="bg-[var(--value1)_var(--value2)]"></div>
<div class="bg-[color:var(--value1)_var(--value2)]"></div>
<div class="bg-[linear-gradient(to_left,rgb(var(--green)),blue)]"></div>
<div class="bg-[url('/path-to-image.png')] bg-[url:var(--url)]"></div>
<div class="bg-[linear-gradient(#eee,#fff)]"></div>