Repository: tailwindlabs/tailwindcss
Pull request #5460 (2021-09-09 14:49:57)

Replace underscore with space in arbitrary values
This PR changes how we handle spaces in arbitrary values by automatically converting underscores to spaces instead of relying on commas.

Prior to this, certain plugins supported using commas in place of spaces, and Tailwind would convert those commas to spaces for you automatically. This was needed because we of course can't use spaces in class names (the browser will see the space as the end of the previous class and the start of the next class).

```html
<!-- Input -->
<div class="grid-cols-[1fr,auto,2fr]"></div>

<!-- Generated CSS -->
<style>
  .grid-cols-\[1fr\,auto\,2fr\] {
    grid-template-columns: 1fr auto 2fr;
  }
</style>
```

This seemed like an elegant way to handle this initially, but there are several situations that require spaces in CSS where using a comma is incredibly confusing to read. For example:

```html
<div class="col-[span,3/4]">
```

The desired CSS output here is `grid-column: span 3 / 4` but the comma makes it look like the `3/4` should be thought of as a single unit, when it's actually the `span,3` part that represents a logical unit of the property value.

Once this PR is merged, you would instead write that HTML like so:

```html
<div class="col-[span_3/4]">
````

...or with additional underscores around the `/` if you needed/wanted to be more explicit:

```html
<div class="col-[span_3_/_4]">
```

The underscores are unfortunately uglier than commas, but ultimately I think it is many times clearer, as developers are already used to using underscores to represent spaces in snake_case identifiers in their code.

Commas will still work for the small number of core plugins where we had implemented it (`gridTemplateColumns`, `gridTemplateRows`, `objectPosition`) to maximize backwards compatibility, but going forward we will only document underscores.

This solution brings support for space separated arbitrary values to every other plugin in Tailwind automatically, which is nice since comma support had to be implemented on a plugin-by-plugin basis.

This PR also supports escaping underscores if you really do need them to be present in the final output, by preceding the underscore with a `\`:

```html
<div class="content-['snake\_case']">
```


Patch:
diff --git a/src/corePlugins.js b/src/corePlugins.js
index 12228c7b..f9a9a97c 100644
--- a/src/corePlugins.js
+++ b/src/corePlugins.js
@@ -9,7 +9,6 @@ import withAlphaVariable, { withAlphaValue } from './util/withAlphaVariable'
 import toColorValue from './util/toColorValue'
 import isPlainObject from './util/isPlainObject'
 import transformThemeValue from './util/transformThemeValue'
-import nameClass from './util/nameClass'
 import {
   applyPseudoToMarker,
   updateLastClasses,
@@ -17,7 +16,6 @@ import {
   transformAllSelectors,
   transformAllClasses,
   transformLastClasses,
-  asList,
   asLength,
   asLookupValue,
 } from './util/pluginUtils'
@@ -829,16 +827,12 @@ export let gridAutoFlow = ({ addUtilities }) => {
 }
 
 export let gridAutoRows = createUtilityPlugin('gridAutoRows', [['auto-rows', ['gridAutoRows']]])
-export let gridTemplateColumns = createUtilityPlugin(
-  'gridTemplateColumns',
-  [['grid-cols', ['gridTemplateColumns']]],
-  { resolveArbitraryValue: asList }
-)
-export let gridTemplateRows = createUtilityPlugin(
-  'gridTemplateRows',
-  [['grid-rows', ['gridTemplateRows']]],
-  { resolveArbitraryValue: asList }
-)
+export let gridTemplateColumns = createUtilityPlugin('gridTemplateColumns', [
+  ['grid-cols', ['gridTemplateColumns']],
+])
+export let gridTemplateRows = createUtilityPlugin('gridTemplateRows', [
+  ['grid-rows', ['gridTemplateRows']],
+])
 
 export let flexDirection = ({ addUtilities }) => {
   addUtilities({
@@ -1431,12 +1425,8 @@ export let objectFit = ({ addUtilities }) => {
     '.object-scale-down': { 'object-fit': 'scale-down' },
   })
 }
+export let objectPosition = createUtilityPlugin('objectPosition', [['object', ['object-position']]])
 
-export let objectPosition = createUtilityPlugin(
-  'objectPosition',
-  [['object', ['object-position']]],
-  { resolveArbitraryValue: asList }
-)
 export let padding = createUtilityPlugin('padding', [
   ['p', ['padding']],
   [
@@ -1715,7 +1705,7 @@ export let boxShadow = (() => {
           }
         },
       },
-      { values: theme('boxShadow'), type: 'lookup' }
+      { values: theme('boxShadow') }
     )
   }
 })()
@@ -1867,23 +1857,21 @@ export let contrast = ({ matchUtilities, theme }) => {
   )
 }
 
-export let dropShadow = ({ addUtilities, theme }) => {
-  let utilities = Object.fromEntries(
-    Object.entries(theme('dropShadow') ?? {}).map(([modifier, value]) => {
-      return [
-        nameClass('drop-shadow', modifier),
-        {
+export let dropShadow = ({ matchUtilities, theme }) => {
+  matchUtilities(
+    {
+      'drop-shadow': (value) => {
+        return {
           '--tw-drop-shadow': Array.isArray(value)
             ? value.map((v) => `drop-shadow(${v})`).join(' ')
             : `drop-shadow(${value})`,
           '@defaults filter': {},
           filter: 'var(--tw-filter)',
-        },
-      ]
-    })
+        }
+      },
+    },
+    { values: theme('dropShadow') }
   )
-
-  addUtilities(utilities)
 }
 
 export let grayscale = ({ matchUtilities, theme }) => {
diff --git a/src/util/createUtilityPlugin.js b/src/util/createUtilityPlugin.js
index 48985b10..5d03df62 100644
--- a/src/util/createUtilityPlugin.js
+++ b/src/util/createUtilityPlugin.js
@@ -1,9 +1,8 @@
 import transformThemeValue from './transformThemeValue'
-import { asValue, asList, asColor, asAngle, asLength, asLookupValue } from '../util/pluginUtils'
+import { asValue, asColor, asAngle, asLength, asLookupValue } from '../util/pluginUtils'
 
 let asMap = new Map([
   [asValue, 'any'],
-  [asList, 'list'],
   [asColor, 'color'],
   [asAngle, 'angle'],
   [asLength, 'length'],
diff --git a/src/util/pluginUtils.js b/src/util/pluginUtils.js
index 373cd15d..bdbc3278 100644
--- a/src/util/pluginUtils.js
+++ b/src/util/pluginUtils.js
@@ -1,5 +1,4 @@
 import selectorParser from 'postcss-selector-parser'
-import postcss from 'postcss'
 import escapeCommas from './escapeCommas'
 import { withAlphaValue } from './withAlphaVariable'
 import isKeyframeRule from './isKeyframeRule'
@@ -153,11 +152,7 @@ export function transformLastClasses(transformClass, { wrap, withRule } = {}) {
   }
 }
 
-export function asValue(
-  modifier,
-  lookup = {},
-  { validate = () => true, transform = (v) => v } = {}
-) {
+export function asValue(modifier, lookup = {}, { validate = () => true } = {}) {
   let value = lookup[modifier]
 
   if (value !== undefined) {
@@ -174,8 +169,14 @@ export function asValue(
     return undefined
   }
 
+  // convert `_` to ` `, escept for escaped underscores `\_`
+  value = value
+    .replace(/([^\\])_/g, '$1 ')
+    .replace(/^_/g, ' ')
+    .replace(/\\_/g, '_')
+
   // add spaces around operators inside calc() that do not follow an operator or (
-  return transform(value).replace(
+  return value.replace(
     /(-?\d*\.?\d(?!\b-.+[,)](?![^+\-/*])\D)(?:%|[a-z]+)?|\))([+\-/*])/g,
     '$1 $2 '
   )
@@ -190,20 +191,6 @@ export function asUnit(modifier, units, lookup = {}) {
         new RegExp(`^calc\\(.+?${unitsPattern}`).test(value)
       )
     },
-    transform: (value) => {
-      return value
-    },
-  })
-}
-
-export function asList(modifier, lookup = {}) {
-  return asValue(modifier, lookup, {
-    transform: (value) => {
-      return postcss.list
-        .comma(value)
-        .map((v) => v.replace(/,/g, ', '))
-        .join(' ')
-    },
   })
 }
 
@@ -281,7 +268,6 @@ export function asLookupValue(modifier, lookup = {}) {
 
 let typeMap = {
   any: asValue,
-  list: asList,
   color: asColor,
   angle: asAngle,
   length: asLength,
diff --git a/src/util/transformThemeValue.js b/src/util/transformThemeValue.js
index 5b24ee71..dbd7a3e2 100644
--- a/src/util/transformThemeValue.js
+++ b/src/util/transformThemeValue.js
@@ -1,3 +1,5 @@
+import postcss from 'postcss'
+
 export default function transformThemeValue(themeSection) {
   if (['fontSize', 'outline'].includes(themeSection)) {
     return (value) => (Array.isArray(value) ? value[0] : value)
@@ -21,6 +23,12 @@ export default function transformThemeValue(themeSection) {
     return (value) => (Array.isArray(value) ? value.join(', ') : value)
   }
 
+  // For backwards compatibility reasons, before we switched to underscores
+  // instead of commas for arbitrary values.
+  if (['gridTemplateColumns', 'gridTemplateRows', 'objectPosition'].includes(themeSection)) {
+    return (value) => (typeof value === 'string' ? postcss.list.comma(value).join(' ') : value)
+  }
+
   if (themeSection === 'colors') {
     return (value) => (typeof value === 'function' ? value({}) : value)
   }


Test patch:
diff --git a/tests/arbitrary-values.test.js b/tests/arbitrary-values.test.js
index e75d7f98..2472d7a7 100644
--- a/tests/arbitrary-values.test.js
+++ b/tests/arbitrary-values.test.js
@@ -1,7 +1,7 @@
 import fs from 'fs'
 import path from 'path'
 
-import { run } from './util/run'
+import { run, html, css } from './util/run'
 
 test('arbitrary values', () => {
   let config = {
@@ -15,3 +15,96 @@ test('arbitrary values', () => {
     expect(result.css).toMatchFormattedCss(expected)
   })
 })
+
+it('should convert _ to spaces', () => {
+  let config = {
+    content: [
+      {
+        raw: html`
+          <div class="grid-cols-[200px_repeat(auto-fill,minmax(15%,100px))_300px]"></div>
+          <div class="grid-rows-[200px_repeat(auto-fill,minmax(15%,100px))_300px]"></div>
+          <div class="shadow-[0px_0px_4px_black]"></div>
+          <div class="rounded-[0px_4px_4px_0px]"></div>
+          <div class="m-[8px_4px]"></div>
+          <div class="p-[8px_4px]"></div>
+          <div class="flex-[1_1_100%]"></div>
+          <div class="col-[span_3_/_span_8]"></div>
+          <div class="row-[span_3_/_span_8]"></div>
+          <div class="auto-cols-[minmax(0,_1fr)]"></div>
+          <div class="drop-shadow-[0px_1px_3px_black]"></div>
+          <div class="content-[_hello_world_]"></div>
+        `,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchFormattedCss(css`
+      .col-\\[span_3_\\/_span_8\\] {
+        grid-column: span 3 / span 8;
+      }
+
+      .row-\\[span_3_\\/_span_8\\] {
+        grid-row: span 3 / span 8;
+      }
+
+      .m-\\[8px_4px\\] {
+        margin: 8px 4px;
+      }
+
+      .flex-\\[1_1_100\\%\\] {
+        flex: 1 1 100%;
+      }
+
+      .auto-cols-\\[minmax\\(0\\2c _1fr\\)\\] {
+        grid-auto-columns: minmax(0, 1fr);
+      }
+
+      .grid-cols-\\[200px_repeat\\(auto-fill\\2c minmax\\(15\\%\\2c 100px\\)\\)_300px\\] {
+        grid-template-columns: 200px repeat(auto-fill, minmax(15%, 100px)) 300px;
+      }
+
+      .grid-rows-\\[200px_repeat\\(auto-fill\\2c minmax\\(15\\%\\2c 100px\\)\\)_300px\\] {
+        grid-template-rows: 200px repeat(auto-fill, minmax(15%, 100px)) 300px;
+      }
+
+      .rounded-\\[0px_4px_4px_0px\\] {
+        border-radius: 0px 4px 4px 0px;
+      }
+
+      .p-\\[8px_4px\\] {
+        padding: 8px 4px;
+      }
+
+      .shadow-\\[0px_0px_4px_black\\] {
+        --tw-shadow: 0px 0px 4px black;
+        box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000),
+          var(--tw-shadow);
+      }
+
+      .drop-shadow-\\[0px_1px_3px_black\\] {
+        --tw-drop-shadow: drop-shadow(0px 1px 3px black);
+        filter: var(--tw-filter);
+      }
+      .content-\\[_hello_world_\\] {
+        content: hello world;
+      }
+    `)
+  })
+})
+
+it('should not convert escaped underscores with spaces', () => {
+  let config = {
+    content: [{ raw: html` <div class="content-['snake\\_case']"></div> ` }],
+    corePlugins: { preflight: false },
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchFormattedCss(css`
+      .content-\\[\\'snake\\\\_case\\'\\] {
+        content: 'snake_case';
+      }
+    `)
+  })
+})