Repository: tailwindlabs/tailwindcss (TypeScript)

Issue:
Compound background-image JIT not working
**What version of Tailwind CSS are you using?**

tailwindcss@3.0.24

```
Operating System:
      Platform: linux
      Arch: x64
      Version: #44-Ubuntu SMP Thu Mar 24 15:35:05 UTC 2022
    Binaries:
      Node: 14.18.1
      npm: 8.6.0
      Yarn: 1.22.18
      pnpm: N/A
    Relevant packages:
      next: 12.1.5
      react: 18.0.0
      react-dom: 18.0.0
```
**What browser are you using?**

Firefox

Doing compound bg-[] for background images is not working.
e.g.
```
bg-[url('/media/left-side-bar.png'),linear-gradient(to_right,_#e2d9d4,_#f3eeeb)]
```
Either of those work by themselves works, but when it is joined it is not working.

When I hover over it the CSS class it says it will create is correct.
```css
.bg-\[url\(\'\/media\/left-side-bar\.png\'\)\2c _linear-gradient\(to_right\2c _\#e2d9d4\2c _\#f3eeeb\)\] {
    background-image: url('/media/left-side-bar.png'), linear-gradient(to right, #e2d9d4, #f3eeeb);
}
```

https://play.tailwindcss.com/5sCBtDy3RZ


Hints:

Patch:
diff --git a/src/lib/defaultExtractor.js b/src/lib/defaultExtractor.js
--- a/src/lib/defaultExtractor.js
+++ b/src/lib/defaultExtractor.js
@@ -1,42 +1,160 @@
-const PATTERNS = [
-  /(?:\['([^'\s]+[^<>"'`\s:\\])')/.source, // ['text-lg' -> text-lg
-  /(?:\["([^"\s]+[^<>"'`\s:\\])")/.source, // ["text-lg" -> text-lg
-  /(?:\[`([^`\s]+[^<>"'`\s:\\])`)/.source, // [`text-lg` -> text-lg
-  /([^${(<>"'`\s]*\[\w*'[^"`\s]*'?\])/.source, // font-['some_font',sans-serif]
-  /([^${(<>"'`\s]*\[\w*"[^'`\s]*"?\])/.source, // font-["some_font",sans-serif]
-  /([^<>"'`\s]*\[\w*\('[^"'`\s]*'\)\])/.source, // bg-[url('...')]
-  /([^<>"'`\s]*\[\w*\("[^"'`\s]*"\)\])/.source, // bg-[url("...")]
-  /([^<>"'`\s]*\[\w*\('[^"`\s]*'\)\])/.source, // bg-[url('...'),url('...')]
-  /([^<>"'`\s]*\[\w*\("[^'`\s]*"\)\])/.source, // bg-[url("..."),url("...")]
-  /([^<>"'`\s]*\[[^<>"'`\s]*\('[^"`\s]*'\)+\])/.source, // h-[calc(100%-theme('spacing.1'))]
-  /([^<>"'`\s]*\[[^<>"'`\s]*\("[^'`\s]*"\)+\])/.source, // h-[calc(100%-theme("spacing.1"))]
-  /([^${(<>"'`\s]*\['[^"'`\s]*'\])/.source, // `content-['hello']` but not `content-['hello']']`
-  /([^${(<>"'`\s]*\["[^"'`\s]*"\])/.source, // `content-["hello"]` but not `content-["hello"]"]`
-  /([^<>"'`\s]*\[[^<>"'`\s]*:[^\]\s]*\])/.source, // `[attr:value]`
-  /([^<>"'`\s]*\[[^<>"'`\s]*:'[^"'`\s]*'\])/.source, // `[content:'hello']` but not `[content:"hello"]`
-  /([^<>"'`\s]*\[[^<>"'`\s]*:"[^"'`\s]*"\])/.source, // `[content:"hello"]` but not `[content:'hello']`
-  /([^<>"'`\s]*\[[^"'`\s]+\][^<>"'`\s]*)/.source, // `fill-[#bada55]`, `fill-[#bada55]/50`
-  /([^"'`\s]*[^<>"'`\s:\\])/.source, //  `<sm:underline`, `md>:font-bold`
-  /([^<>"'`\s]*[^"'`\s:\\])/.source, //  `px-1.5`, `uppercase` but not `uppercase:`
-
-  // Arbitrary properties
-  // /([^"\s]*\[[^\s]+?\][^"\s]*)/.source,
-  // /([^'\s]*\[[^\s]+?\][^'\s]*)/.source,
-  // /([^`\s]*\[[^\s]+?\][^`\s]*)/.source,
-].join('|')
-
-const BROAD_MATCH_GLOBAL_REGEXP = new RegExp(PATTERNS, 'g')
-const INNER_MATCH_GLOBAL_REGEXP = /[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g
+import * as regex from './regex'
+
+let patterns = Array.from(buildRegExps())
 
 /**
  * @param {string} content
  */
 export function defaultExtractor(content) {
-  let broadMatches = content.matchAll(BROAD_MATCH_GLOBAL_REGEXP)
-  let innerMatches = content.match(INNER_MATCH_GLOBAL_REGEXP) || []
-  let results = [...broadMatches, ...innerMatches].flat().filter((v) => v !== undefined)
+  /** @type {(string|string)[]} */
+  let results = []
+
+  for (let pattern of patterns) {
+    results.push(...(content.match(pattern) ?? []))
+  }
+
+  return results.filter((v) => v !== undefined).map(clipAtBalancedParens)
+}
+
+function* buildRegExps() {
+  yield regex.pattern([
+    // Variants
+    /((?=([^\s"'\\\[]+:))\2)?/,
+
+    // Important (optional)
+    /!?/,
+
+    regex.any([
+      // Arbitrary properties
+      /\[[^\s:'"]+:[^\s\]]+\]/,
+
+      // Utilities
+      regex.pattern([
+        // Utility Name / Group Name
+        /-?(?:\w+)/,
+
+        // Normal/Arbitrary values
+        regex.optional(
+          regex.any([
+            regex.pattern([
+              // Arbitrary values
+              /-\[[^\s:]+\]/,
+
+              // Not immediately followed by an `{[(`
+              /(?![{([]])/,
+
+              // optionally followed by an opacity modifier
+              /(?:\/[^\s'"\\$]*)?/,
+            ]),
+
+            regex.pattern([
+              // Arbitrary values
+              /-\[[^\s]+\]/,
+
+              // Not immediately followed by an `{[(`
+              /(?![{([]])/,
+
+              // optionally followed by an opacity modifier
+              /(?:\/[^\s'"\\$]*)?/,
+            ]),
+
+            // Normal values w/o quotes — may include an opacity modifier
+            /[-\/][^\s'"\\$={]*/,
+          ])
+        ),
+      ]),
+    ]),
+  ])
+
+  // 5. Inner matches
+  // yield /[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g
+}
+
+// We want to capture any "special" characters
+// AND the characters immediately following them (if there is one)
+let SPECIALS = /([\[\]'"`])([^\[\]'"`])?/g
+let ALLOWED_CLASS_CHARACTERS = /[^"'`\s<>\]]+/
+
+/**
+ * Clips a string ensuring that parentheses, quotes, etc… are balanced
+ * Used for arbitrary values only
+ *
+ * We will go past the end of the balanced parens until we find a non-class character
+ *
+ * Depth matching behavior:
+ * w-[calc(100%-theme('spacing[some_key][1.5]'))]']
+ *   ┬    ┬          ┬┬       ┬        ┬┬   ┬┬┬┬┬┬┬
+ *   1    2          3        4        34   3 210 END
+ *   ╰────┴──────────┴────────┴────────┴┴───┴─┴┴┴
+ *
+ * @param {string} input
+ */
+function clipAtBalancedParens(input) {
+  // We are care about this for arbitrary values
+  if (!input.includes('-[')) {
+    return input
+  }
+
+  let depth = 0
+  let openStringTypes = []
+
+  // Find all parens, brackets, quotes, etc
+  // Stop when we end at a balanced pair
+  // This is naive and will treat mismatched parens as balanced
+  // This shouldn't be a problem in practice though
+  let matches = input.matchAll(SPECIALS)
+
+  // We can't use lookbehind assertions because we have to support Safari
+  // So, instead, we've emulated it using capture groups and we'll re-work the matches to accommodate
+  matches = Array.from(matches).flatMap((match) => {
+    const [, ...groups] = match
+
+    return groups.map((group, idx) =>
+      Object.assign([], match, {
+        index: match.index + idx,
+        0: group,
+      })
+    )
+  })
+
+  for (let match of matches) {
+    let char = match[0]
+    let inStringType = openStringTypes[openStringTypes.length - 1]
+
+    if (char === inStringType) {
+      openStringTypes.pop()
+    } else if (char === "'" || char === '"' || char === '`') {
+      openStringTypes.push(char)
+    }
+
+    if (inStringType) {
+      continue
+    } else if (char === '[') {
+      depth++
+      continue
+    } else if (char === ']') {
+      depth--
+      continue
+    }
+
+    // We've gone one character past the point where we should stop
+    // This means that there was an extra closing `]`
+    // We'll clip to just before it
+    if (depth < 0) {
+      return input.substring(0, match.index)
+    }
+
+    // We've finished balancing the brackets but there still may be characters that can be included
+    // For example in the class `text-[#336699]/[.35]`
+    // The depth goes to `0` at the closing `]` but goes up again at the `[`
+
+    // If we're at zero and encounter a non-class character then we clip the class there
+    if (depth === 0 && !ALLOWED_CLASS_CHARACTERS.test(char)) {
+      return input.substring(0, match.index)
+    }
+  }
 
-  return results
+  return input
 }
 
 // Regular utilities
diff --git a/src/lib/expandApplyAtRules.js b/src/lib/expandApplyAtRules.js
--- a/src/lib/expandApplyAtRules.js
+++ b/src/lib/expandApplyAtRules.js
@@ -34,6 +34,15 @@ function extractClasses(node) {
   return Object.assign(classes, { groups: normalizedGroups })
 }
 
+let selectorExtractor = parser((root) => root.nodes.map((node) => node.toString()))
+
+/**
+ * @param {string} ruleSelectors
+ */
+function extractSelectors(ruleSelectors) {
+  return selectorExtractor.transformSync(ruleSelectors)
+}
+
 function extractBaseCandidates(candidates, separator) {
   let baseClasses = new Set()
 
@@ -295,10 +304,9 @@ function processApply(root, context, localCache) {
   function replaceSelector(selector, utilitySelectors, candidate) {
     let needle = `.${escapeClassName(candidate)}`
     let needles = [...new Set([needle, needle.replace(/\\2c /g, '\\,')])]
-    let utilitySelectorsList = utilitySelectors.split(/\s*(?<!\\)\,(?![^(]*\))\s*/g)
+    let utilitySelectorsList = extractSelectors(utilitySelectors)
 
-    return selector
-      .split(/\s*(?<!\\)\,(?![^(]*\))\s*/g)
+    return extractSelectors(selector)
       .map((s) => {
         let replaced = []
 
diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js
--- a/src/lib/expandTailwindAtRules.js
+++ b/src/lib/expandTailwindAtRules.js
@@ -17,8 +17,8 @@ const builtInTransformers = {
   svelte: (content) => content.replace(/(?:^|\s)class:/g, ' '),
 }
 
-function getExtractor(tailwindConfig, fileExtension) {
-  let extractors = tailwindConfig.content.extract
+function getExtractor(context, fileExtension) {
+  let extractors = context.tailwindConfig.content.extract
 
   return (
     extractors[fileExtension] ||
@@ -165,7 +165,7 @@ export default function expandTailwindAtRules(context) {
 
     for (let { content, extension } of context.changedContent) {
       let transformer = getTransformer(context.tailwindConfig, extension)
-      let extractor = getExtractor(context.tailwindConfig, extension)
+      let extractor = getExtractor(context, extension)
       getClassCandidates(transformer(content), extractor, candidates, seen)
     }
 
diff --git a/src/lib/regex.js b/src/lib/regex.js
new file mode 100644
--- /dev/null
+++ b/src/lib/regex.js
@@ -0,0 +1,74 @@
+const REGEX_SPECIAL = /[\\^$.*+?()[\]{}|]/g
+const REGEX_HAS_SPECIAL = RegExp(REGEX_SPECIAL.source)
+
+/**
+ * @param {string|RegExp|Array<string|RegExp>} source
+ */
+function toSource(source) {
+  source = Array.isArray(source) ? source : [source]
+
+  source = source.map((item) => (item instanceof RegExp ? item.source : item))
+
+  return source.join('')
+}
+
+/**
+ * @param {string|RegExp|Array<string|RegExp>} source
+ */
+export function pattern(source) {
+  return new RegExp(toSource(source), 'g')
+}
+
+/**
+ * @param {string|RegExp|Array<string|RegExp>} source
+ */
+export function withoutCapturing(source) {
+  return new RegExp(`(?:${toSource(source)})`, 'g')
+}
+
+/**
+ * @param {Array<string|RegExp>} sources
+ */
+export function any(sources) {
+  return `(?:${sources.map(toSource).join('|')})`
+}
+
+/**
+ * @param {string|RegExp} source
+ */
+export function optional(source) {
+  return `(?:${toSource(source)})?`
+}
+
+/**
+ * @param {string|RegExp|Array<string|RegExp>} source
+ */
+export function zeroOrMore(source) {
+  return `(?:${toSource(source)})*`
+}
+
+/**
+ * Generate a RegExp that matches balanced brackets for a given depth
+ * We have to specify a depth because JS doesn't support recursive groups using ?R
+ *
+ * Based on https://stackoverflow.com/questions/17759004/how-to-match-string-within-parentheses-nested-in-java/17759264#17759264
+ *
+ * @param {string|RegExp|Array<string|RegExp>} source
+ */
+export function nestedBrackets(open, close, depth = 1) {
+  return withoutCapturing([
+    escape(open),
+    /[^\s]*/,
+    depth === 1
+      ? `[^${escape(open)}${escape(close)}\s]*`
+      : any([`[^${escape(open)}${escape(close)}\s]*`, nestedBrackets(open, close, depth - 1)]),
+    /[^\s]*/,
+    escape(close),
+  ])
+}
+
+export function escape(string) {
+  return string && REGEX_HAS_SPECIAL.test(string)
+    ? string.replace(REGEX_SPECIAL, '\\$&')
+    : string || ''
+}


Test patch:
diff --git a/tests/arbitrary-values.test.css b/tests/arbitrary-values.test.css
--- a/tests/arbitrary-values.test.css
+++ b/tests/arbitrary-values.test.css
@@ -316,6 +316,9 @@
 .cursor-\[url\(hand\.cur\)_2_2\2c pointer\] {
   cursor: url(hand.cur) 2 2, pointer;
 }
+.cursor-\[url\(\'\.\/path_to_hand\.cur\'\)_2_2\2c pointer\] {
+  cursor: url("./path_to_hand.cur") 2 2, pointer;
+}
 .cursor-\[var\(--value\)\] {
   cursor: var(--value);
 }
diff --git a/tests/basic-usage.test.js b/tests/basic-usage.test.js
--- a/tests/basic-usage.test.js
+++ b/tests/basic-usage.test.js
@@ -401,3 +401,32 @@ it('should generate styles using :not(.unknown-class) even if `.unknown-class` d
     `)
   })
 })
+
+it('supports multiple backgrounds as arbitrary values even if only some are quoted', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div
+          class="bg-[url('/images/one-two-three.png'),linear-gradient(to_right,_#eeeeee,_#000000)]"
+        ></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .bg-\[url\(\'\/images\/one-two-three\.png\'\)\2c
+        linear-gradient\(to_right\2c
+        _\#eeeeee\2c
+        _\#000000\)\] {
+        background-image: url('/images/one-two-three.png'),
+          linear-gradient(to right, #eeeeee, #000000);
+      }
+    `)
+  })
+})
diff --git a/tests/default-extractor.test.js b/tests/default-extractor.test.js
--- a/tests/default-extractor.test.js
+++ b/tests/default-extractor.test.js
@@ -56,7 +56,11 @@ const htmlExamples = html`
     let classes11 = ['hover:']
     let classes12 = ['hover:\'abc']
     let classes13 = ["lg:text-[4px]"]
-    let classes14 = ["<div class='hover:test'>"]
+    let classes14 = ["<div class='hover:underline'>"]
+    let classes15 = ["<div class='hover:test'>"] // unknown so dont generate
+    let classes16 = ["font-[arbitrary,'arbitrary_with_space']"]
+    let classes17 = ["font-['arbitrary_with_space','arbitrary_2']"]
+    let classes18 = ["bg-[url('/images/one-two-three.png'),linear-gradient(to_right,_#eeeeee,_#000000)]"]
 
     let obj = {
       lowercase: true,
@@ -73,6 +77,10 @@ const htmlExamples = html`
       "h-[109px]": true
     }
   </script>
+  <script type="text/twig">
+    element['#border_color']|default('border-[color:var(--color,theme(colors.cyan.500))]')
+    {% if settings == 'foo'%}translate-x-[var(--scroll-offset)]{% endif %}
+  </script>
 `
 
 const includes = [
@@ -133,11 +141,16 @@ const includes = [
   `lg:text-[4px]`,
   `lg:text-[24px]`,
   `content-['>']`,
-  `hover:test`,
+  `hover:underline`,
   `overflow-scroll`,
   `[--y:theme(colors.blue.500)]`,
   `w-[calc(100%-theme('spacing.1'))]`,
   `w-[calc(100%-theme("spacing.2"))]`,
+  `border-[color:var(--color,theme(colors.cyan.500))]`,
+  `translate-x-[var(--scroll-offset)]`,
+  `font-[arbitrary,'arbitrary_with_space']`,
+  `font-['arbitrary_with_space','arbitrary_2']`,
+  `bg-[url('/images/one-two-three.png'),linear-gradient(to_right,_#eeeeee,_#000000)]`,
 ]
 
 const excludes = [
@@ -145,6 +158,7 @@ const excludes = [
   'hover:',
   "hover:'abc",
   `font-bold`,
+  `<div class='hover:underline'>`,
   `<div class='hover:test'>`,
   `test`,
 ]
@@ -193,7 +207,7 @@ test('basic utility classes', async () => {
   expect(extractions).toContain('pointer-events-none')
 })
 
-test('modifiers with basic utilites', async () => {
+test('modifiers with basic utilities', async () => {
   const extractions = defaultExtractor(`
     <div class="hover:text-center hover:focus:font-bold"></div>
   `)
@@ -394,26 +408,26 @@ test('with single quotes array within template literal', async () => {
   const extractions = defaultExtractor(`<div class=\`\${['pr-1.5']}\`></div>`)
 
   expect(extractions).toContain('pr-1.5')
-  expect(extractions).toContain('pr-1')
+  expect(extractions).not.toContain('pr-1')
 })
 
 test('with double quotes array within template literal', async () => {
   const extractions = defaultExtractor(`<div class=\`\${["pr-1.5"]}\`></div>`)
 
   expect(extractions).toContain('pr-1.5')
-  expect(extractions).toContain('pr-1')
+  expect(extractions).not.toContain('pr-1')
 })
 
 test('with single quotes array within function', async () => {
   const extractions = defaultExtractor(`document.body.classList.add(['pl-1.5'].join(" "));`)
 
   expect(extractions).toContain('pl-1.5')
-  expect(extractions).toContain('pl-1')
+  expect(extractions).not.toContain('pl-1')
 })
 
 test('with double quotes array within function', async () => {
   const extractions = defaultExtractor(`document.body.classList.add(["pl-1.5"].join(" "));`)
 
   expect(extractions).toContain('pl-1.5')
-  expect(extractions).toContain('pl-1')
+  expect(extractions).not.toContain('pl-1')
 })