From 9d48e0460b3557e74b65992aa5986a664b178a3a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 13 Sep 2022 21:29:26 +0200 Subject: [PATCH 1/7] improve split logic by delimiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
``` 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 │
· ──┬── ┬ ─────┬───── · │ │ ╰─────── 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 │
· ┬ ┬ ┬ · ╰─┴─┴── 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 │
· ┬ ┬ ┬ ┬ ┬ ┬ ┬ · │ │ │ │ ╰───┴───┴── Guarded by parens · │ │ │ ╰────────────────── We will split here · ╰─┴─┴──────────────────────────────── Guarded by parens │ └─ ``` --- src/util/dataTypes.js | 38 +++++++++++++++++++++++++------- tests/arbitrary-values.test.css | 3 +++ tests/arbitrary-values.test.html | 1 + 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/util/dataTypes.js b/src/util/dataTypes.js index 1491cecd9601..a5bf8848ed8c 100644 --- a/src/util/dataTypes.js +++ b/src/util/dataTypes.js @@ -5,8 +5,30 @@ 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. +function splitBy(value, delimiter, exceptIn = [['(', ')']]) { + let except = new Map(exceptIn) + + let stack = [] + let parts = [] + let lastPos = 0 + + for (let [idx, char] of value.split('').entries()) { + if (char === delimiter && stack.length <= 0) { + parts.push(value.slice(lastPos, idx)) + lastPos = idx + 1 /* Skip delimiter itself */ + } + + if (except.has(char)) { + stack.push(except.get(char)) + } else if (stack[stack.length - 1] === char) { + stack.pop() + } + } + + parts.push(value.slice(lastPos)) + + return parts +} // This is not a data type, but rather a function that can normalize the // correct values. @@ -61,7 +83,7 @@ export function number(value) { } export function percentage(value) { - return value.split(UNDERSCORE).every((part) => { + return splitBy(value, '_').every((part) => { return /%$/g.test(part) || cssFunctions.some((fn) => new RegExp(`^${fn}\\(.+?%`).test(part)) }) } @@ -86,7 +108,7 @@ let lengthUnits = [ ] let lengthUnitsPattern = `(?:${lengthUnits.join('|')})` export function length(value) { - return value.split(UNDERSCORE).every((part) => { + return splitBy(value, '_').every((part) => { return ( part === '0' || new RegExp(`${lengthUnitsPattern}$`).test(part) || @@ -115,7 +137,7 @@ export function shadow(value) { export function color(value) { let colors = 0 - let result = value.split(UNDERSCORE).every((part) => { + let result = splitBy(value, '_').every((part) => { part = normalize(part) if (part.startsWith('var(')) return true @@ -130,7 +152,7 @@ export function color(value) { export function image(value) { let images = 0 - let result = value.split(COMMA).every((part) => { + let result = splitBy(value, ',').every((part) => { part = normalize(part) if (part.startsWith('var(')) return true @@ -171,7 +193,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 = splitBy(value, '_').every((part) => { part = normalize(part) if (part.startsWith('var(')) return true @@ -189,7 +211,7 @@ export function position(value) { export function familyName(value) { let fonts = 0 - let result = value.split(COMMA).every((part) => { + let result = splitBy(value, ',').every((part) => { part = normalize(part) if (part.startsWith('var(')) return true diff --git a/tests/arbitrary-values.test.css b/tests/arbitrary-values.test.css index 9cf6b8325898..df4e216c9d5e 100644 --- a/tests/arbitrary-values.test.css +++ b/tests/arbitrary-values.test.css @@ -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'); } diff --git a/tests/arbitrary-values.test.html b/tests/arbitrary-values.test.html index 142240b14590..eb011386437c 100644 --- a/tests/arbitrary-values.test.html +++ b/tests/arbitrary-values.test.html @@ -220,6 +220,7 @@
+
From 523df1f0f38d0c9e686e4a227192b5515c9b39a9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 13 Sep 2022 22:20:55 +0200 Subject: [PATCH 2/7] use already existing `splitAtTopLevelOnly` function --- src/util/dataTypes.js | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/src/util/dataTypes.js b/src/util/dataTypes.js index a5bf8848ed8c..9b0e64c51049 100644 --- a/src/util/dataTypes.js +++ b/src/util/dataTypes.js @@ -1,33 +1,13 @@ 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 -function splitBy(value, delimiter, exceptIn = [['(', ')']]) { - let except = new Map(exceptIn) - - let stack = [] - let parts = [] - let lastPos = 0 - - for (let [idx, char] of value.split('').entries()) { - if (char === delimiter && stack.length <= 0) { - parts.push(value.slice(lastPos, idx)) - lastPos = idx + 1 /* Skip delimiter itself */ - } - - if (except.has(char)) { - stack.push(except.get(char)) - } else if (stack[stack.length - 1] === char) { - stack.pop() - } - } - - parts.push(value.slice(lastPos)) - - return parts +function splitBy(value, delimiter) { + return Array.from(splitAtTopLevelOnly(value, delimiter)) } // This is not a data type, but rather a function that can normalize the From 69f5221fd7c851e76b7739ba3efdbb0dfaf51390 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 13 Sep 2022 22:46:13 +0200 Subject: [PATCH 3/7] 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. --- src/util/splitAtTopLevelOnly.js | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/util/splitAtTopLevelOnly.js b/src/util/splitAtTopLevelOnly.js index 8297a6feca4e..d5b560ed209f 100644 --- a/src/util/splitAtTopLevelOnly.js +++ b/src/util/splitAtTopLevelOnly.js @@ -1,5 +1,11 @@ import * as regex from '../lib/regex' +let except = new Map([ + ['(', ')'], + ['[', ']'], + ['{', '}'], +]) + /** * This splits a string on a top-level character. * @@ -15,7 +21,44 @@ import * as regex from '../lib/regex' * @param {string} input * @param {string} separator */ -export function* splitAtTopLevelOnly(input, separator) { +export function splitAtTopLevelOnly(input, separator) { + if (separator.length === 1) { + // Fast pass in case the separator is 1 character long. + // TODO: Handle separators with multiple characters + return splitFast(input, separator) + } + + return splitOriginal(input, separator) +} + +// TODO: Improve this so that we can handle separators of multiple characters long. For now, this is +// just an optimization path. +function splitFast(input, separator) { + let stack = [] + let parts = [] + let lastPos = 0 + + for (let idx = 0; idx < input.length; idx++) { + let char = input[idx] + + if (char === separator && stack.length <= 0) { + parts.push(input.slice(lastPos, idx)) + lastPos = idx + 1 /* Skip separator itself */ + } + + if (except.has(char)) { + stack.push(except.get(char)) + } else if (stack[stack.length - 1] === char) { + stack.pop() + } + } + + parts.push(input.slice(lastPos)) + + return parts +} + +function* splitOriginal(input, separator) { let SPECIALS = new RegExp(`[(){}\\[\\]${regex.escape(separator)}]`, 'g') let depth = 0 From 5edf7c7c645420ec31bbc36eee208f2b8d78d89f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 13 Sep 2022 22:48:32 +0200 Subject: [PATCH 4/7] use `splitAtTopLevelOnly` directly --- src/util/dataTypes.js | 16 ++++++---------- src/util/parseBoxShadowValue.js | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/util/dataTypes.js b/src/util/dataTypes.js index 9b0e64c51049..d7f04f7044bc 100644 --- a/src/util/dataTypes.js +++ b/src/util/dataTypes.js @@ -6,10 +6,6 @@ let cssFunctions = ['min', 'max', 'clamp', 'calc'] // Ref: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Types -function splitBy(value, delimiter) { - return Array.from(splitAtTopLevelOnly(value, delimiter)) -} - // This is not a data type, but rather a function that can normalize the // correct values. export function normalize(value, isRoot = true) { @@ -63,7 +59,7 @@ export function number(value) { } export function percentage(value) { - return splitBy(value, '_').every((part) => { + return splitAtTopLevelOnly(value, '_').every((part) => { return /%$/g.test(part) || cssFunctions.some((fn) => new RegExp(`^${fn}\\(.+?%`).test(part)) }) } @@ -88,7 +84,7 @@ let lengthUnits = [ ] let lengthUnitsPattern = `(?:${lengthUnits.join('|')})` export function length(value) { - return splitBy(value, '_').every((part) => { + return splitAtTopLevelOnly(value, '_').every((part) => { return ( part === '0' || new RegExp(`${lengthUnitsPattern}$`).test(part) || @@ -117,7 +113,7 @@ export function shadow(value) { export function color(value) { let colors = 0 - let result = splitBy(value, '_').every((part) => { + let result = splitAtTopLevelOnly(value, '_').every((part) => { part = normalize(part) if (part.startsWith('var(')) return true @@ -132,7 +128,7 @@ export function color(value) { export function image(value) { let images = 0 - let result = splitBy(value, ',').every((part) => { + let result = splitAtTopLevelOnly(value, ',').every((part) => { part = normalize(part) if (part.startsWith('var(')) return true @@ -173,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 = splitBy(value, '_').every((part) => { + let result = splitAtTopLevelOnly(value, '_').every((part) => { part = normalize(part) if (part.startsWith('var(')) return true @@ -191,7 +187,7 @@ export function position(value) { export function familyName(value) { let fonts = 0 - let result = splitBy(value, ',').every((part) => { + let result = splitAtTopLevelOnly(value, ',').every((part) => { part = normalize(part) if (part.startsWith('var(')) return true diff --git a/src/util/parseBoxShadowValue.js b/src/util/parseBoxShadowValue.js index 16fc8eb1b03a..4be3efa04c7c 100644 --- a/src/util/parseBoxShadowValue.js +++ b/src/util/parseBoxShadowValue.js @@ -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 } From 81b27b43446be191b337c3d0e52d1b91a88e710d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 13 Sep 2022 17:28:52 -0400 Subject: [PATCH 5/7] make split go brrrrrrr --- src/util/splitAtTopLevelOnly.js | 93 +++++---------------------------- 1 file changed, 12 insertions(+), 81 deletions(-) diff --git a/src/util/splitAtTopLevelOnly.js b/src/util/splitAtTopLevelOnly.js index d5b560ed209f..63fd767403ce 100644 --- a/src/util/splitAtTopLevelOnly.js +++ b/src/util/splitAtTopLevelOnly.js @@ -1,11 +1,3 @@ -import * as regex from '../lib/regex' - -let except = new Map([ - ['(', ')'], - ['[', ']'], - ['{', '}'], -]) - /** * This splits a string on a top-level character. * @@ -22,18 +14,6 @@ let except = new Map([ * @param {string} separator */ export function splitAtTopLevelOnly(input, separator) { - if (separator.length === 1) { - // Fast pass in case the separator is 1 character long. - // TODO: Handle separators with multiple characters - return splitFast(input, separator) - } - - return splitOriginal(input, separator) -} - -// TODO: Improve this so that we can handle separators of multiple characters long. For now, this is -// just an optimization path. -function splitFast(input, separator) { let stack = [] let parts = [] let lastPos = 0 @@ -41,14 +21,20 @@ function splitFast(input, separator) { for (let idx = 0; idx < input.length; idx++) { let char = input[idx] - if (char === separator && stack.length <= 0) { - parts.push(input.slice(lastPos, idx)) - lastPos = idx + 1 /* Skip separator itself */ + 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 + } } - if (except.has(char)) { - stack.push(except.get(char)) - } else if (stack[stack.length - 1] === char) { + 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() } } @@ -57,58 +43,3 @@ function splitFast(input, separator) { return parts } - -function* splitOriginal(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 - } -} From 749d9839618386c3cec11cd8dd60bf203bc08da7 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 13 Sep 2022 23:31:01 +0200 Subject: [PATCH 6/7] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d2cf2bc40e..1af2180d1abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From c1e6e8a0f8452cc685b6b68ceca85a8ff7c6999c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 13 Sep 2022 17:36:09 -0400 Subject: [PATCH 7/7] remove unncessary array.from call --- src/lib/generateRules.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 558cd47ebfa3..116813a6d1ff 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -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) {