Repository: tailwindlabs/tailwindcss
Pull request #5511 (2021-09-15 15:40:20)

Pattern safelisting
Currently in the new `JIT` mode, we can't really use a RegExp for safelisting, because we don't have anything to match against. In AOT mode we generated everything, then when we apply the purge step, we could remove everything except of safelisted values.

This PR provides a way to apply safelisting using RegExp as well, but with a slightly different syntax:
```js
  let config = {
    content: [{ raw: html`<div class="uppercase"></div>` }],
    safelist: [
      {
        pattern: /bg-(red)-(100|200)/,
        variants: ['hover', 'focus'],
      },
    ],
  }
```

Sadly we can't generate all possible combinations and then test each option against the RegExp... why not? Well...
We have `100` variants (which you can combine) and we have about `6575` utilities by default.
Instead, we generate the `6575` base utilities. These don't have any variants, e.g.: `text-gray-200`.
We then test the RegExp against these values. If they match, we generate the combinations using the `variants` you provide in the list.



Patch:
diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js
index 6f563efb..ed7c974d 100644
--- a/src/lib/setupContextUtils.js
+++ b/src/lib/setupContextUtils.js
@@ -9,13 +9,14 @@ import parseObjectStyles from '../util/parseObjectStyles'
 import prefixSelector from '../util/prefixSelector'
 import isPlainObject from '../util/isPlainObject'
 import escapeClassName from '../util/escapeClassName'
-import nameClass from '../util/nameClass'
+import nameClass, { formatClass } from '../util/nameClass'
 import { coerceValue } from '../util/pluginUtils'
 import bigSign from '../util/bigSign'
 import * as corePlugins from '../corePlugins'
 import * as sharedState from './sharedState'
 import { env } from './sharedState'
 import { toPath } from '../util/toPath'
+import log from '../util/log'
 
 function insertInto(list, value, { before = [] } = {}) {
   before = [].concat(before)
@@ -146,7 +147,7 @@ function isValidArbitraryValue(value) {
   return true
 }
 
-function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets }) {
+function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) {
   function getConfigValue(path, defaultValue) {
     return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
   }
@@ -241,6 +242,8 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
         let prefixedIdentifier = prefixIdentifier(identifier, options)
         let offset = offsets.components++
 
+        classList.add(prefixedIdentifier)
+
         if (!context.candidateRuleMap.has(prefixedIdentifier)) {
           context.candidateRuleMap.set(prefixedIdentifier, [])
         }
@@ -268,6 +271,8 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
         let prefixedIdentifier = prefixIdentifier(identifier, options)
         let offset = offsets.utilities++
 
+        classList.add(prefixedIdentifier)
+
         if (!context.candidateRuleMap.has(prefixedIdentifier)) {
           context.candidateRuleMap.set(prefixedIdentifier, [])
         }
@@ -293,6 +298,8 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
         let prefixedIdentifier = prefixIdentifier(identifier, options)
         let rule = utilities[identifier]
 
+        classList.add([prefixedIdentifier, options])
+
         function wrapped(modifier) {
           let { type = 'any' } = options
           type = [].concat(type)
@@ -468,10 +475,13 @@ function registerPlugins(plugins, context) {
     user: 0n,
   }
 
+  let classList = new Set()
+
   let pluginApi = buildPluginApi(context.tailwindConfig, context, {
     variantList,
     variantMap,
     offsets,
+    classList,
   })
 
   for (let plugin of plugins) {
@@ -523,6 +533,83 @@ function registerPlugins(plugins, context) {
       variantFunctions.map((variantFunction, idx) => [sort << BigInt(idx), variantFunction])
     )
   }
+
+  //
+  let warnedAbout = new Set([])
+  context.safelist = function () {
+    let safelist = (context.tailwindConfig.safelist ?? []).filter(Boolean)
+    if (safelist.length <= 0) return []
+
+    let output = []
+    let checks = []
+
+    for (let value of safelist) {
+      if (typeof value === 'string') {
+        output.push(value)
+        continue
+      }
+
+      if (value instanceof RegExp) {
+        if (!warnedAbout.has('root-regex')) {
+          log.warn([
+            // TODO: Improve this warning message
+            'RegExp in the safelist option is not supported.',
+            'Please use the object syntax instead: https://tailwindcss.com/docs/...',
+          ])
+          warnedAbout.add('root-regex')
+        }
+        continue
+      }
+
+      checks.push(value)
+    }
+
+    if (checks.length <= 0) return output.map((value) => ({ raw: value, extension: 'html' }))
+
+    let patternMatchingCount = new Map()
+
+    for (let util of classList) {
+      let utils = Array.isArray(util)
+        ? (() => {
+            let [utilName, options] = util
+            return Object.keys(options?.values ?? {}).map((value) => formatClass(utilName, value))
+          })()
+        : [util]
+
+      for (let util of utils) {
+        for (let { pattern, variants = [] } of checks) {
+          // RegExp with the /g flag are stateful, so let's reset the last
+          // index pointer to reset the state.
+          pattern.lastIndex = 0
+
+          if (!patternMatchingCount.has(pattern)) {
+            patternMatchingCount.set(pattern, 0)
+          }
+
+          if (!pattern.test(util)) continue
+
+          patternMatchingCount.set(pattern, patternMatchingCount.get(pattern) + 1)
+
+          output.push(util)
+          for (let variant of variants) {
+            output.push(variant + context.tailwindConfig.separator + util)
+          }
+        }
+      }
+    }
+
+    for (let [regex, count] of patternMatchingCount.entries()) {
+      if (count !== 0) continue
+
+      log.warn([
+        // TODO: Improve this warning message
+        `You have a regex pattern in your "safelist" config (${regex}) that doesn't match any utilities.`,
+        'For more info, visit https://tailwindcss.com/docs/...',
+      ])
+    }
+
+    return output.map((value) => ({ raw: value, extension: 'html' }))
+  }
 }
 
 export function createContext(
diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js
index f0ea6ab2..9b1e71ec 100644
--- a/src/lib/setupTrackingContext.js
+++ b/src/lib/setupTrackingContext.js
@@ -80,6 +80,7 @@ function resolvedChangedContent(context, candidateFiles, fileModifiedMap) {
   let changedContent = context.tailwindConfig.content.content
     .filter((item) => typeof item.raw === 'string')
     .concat(context.tailwindConfig.content.safelist)
+    .concat(context.safelist())
     .map(({ raw, extension }) => ({ content: raw, extension }))
 
   for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) {
diff --git a/src/lib/setupWatchingContext.js b/src/lib/setupWatchingContext.js
index 4e9d772b..1eeeb11c 100644
--- a/src/lib/setupWatchingContext.js
+++ b/src/lib/setupWatchingContext.js
@@ -188,6 +188,7 @@ function resolvedChangedContent(context, candidateFiles) {
   let changedContent = context.tailwindConfig.content.content
     .filter((item) => typeof item.raw === 'string')
     .concat(context.tailwindConfig.content.safelist)
+    .concat(context.safelist())
     .map(({ raw, extension }) => ({ content: raw, extension }))
 
   for (let changedFile of resolveChangedFiles(context, candidateFiles)) {
diff --git a/src/util/nameClass.js b/src/util/nameClass.js
index 2bb0548b..f67b3fd5 100644
--- a/src/util/nameClass.js
+++ b/src/util/nameClass.js
@@ -6,17 +6,21 @@ function asClass(name) {
 }
 
 export default function nameClass(classPrefix, key) {
+  return asClass(formatClass(classPrefix, key))
+}
+
+export function formatClass(classPrefix, key) {
   if (key === 'DEFAULT') {
-    return asClass(classPrefix)
+    return classPrefix
   }
 
   if (key === '-') {
-    return asClass(`-${classPrefix}`)
+    return `-${classPrefix}`
   }
 
   if (key.startsWith('-')) {
-    return asClass(`-${classPrefix}${key}`)
+    return `-${classPrefix}${key}`
   }
 
-  return asClass(`${classPrefix}-${key}`)
+  return `${classPrefix}-${key}`
 }


Test patch:
diff --git a/tests/safelist.test.js b/tests/safelist.test.js
new file mode 100644
index 00000000..6325cfad
--- /dev/null
+++ b/tests/safelist.test.js
@@ -0,0 +1,196 @@
+import { run, html, css } from './util/run'
+
+it('should not safelist anything', () => {
+  let config = {
+    content: [{ raw: html`<div class="uppercase"></div>` }],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchCss(css`
+      .uppercase {
+        text-transform: uppercase;
+      }
+    `)
+  })
+})
+
+it('should safelist strings', () => {
+  let config = {
+    content: [{ raw: html`<div class="uppercase"></div>` }],
+    safelist: ['mt-[20px]', 'font-bold', 'text-gray-200', 'hover:underline'],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchCss(css`
+      .mt-\\[20px\\] {
+        margin-top: 20px;
+      }
+
+      .font-bold {
+        font-weight: 700;
+      }
+
+      .uppercase {
+        text-transform: uppercase;
+      }
+
+      .text-gray-200 {
+        --tw-text-opacity: 1;
+        color: rgb(229 231 235 / var(--tw-text-opacity));
+      }
+
+      .hover\\:underline:hover {
+        text-decoration: underline;
+      }
+    `)
+  })
+})
+
+it('should safelist based on a pattern regex', () => {
+  let config = {
+    content: [{ raw: html`<div class="uppercase"></div>` }],
+    safelist: [
+      {
+        pattern: /bg-(red)-(100|200)/,
+        variants: ['hover'],
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchCss(css`
+      .bg-red-100 {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 226 226 / var(--tw-bg-opacity));
+      }
+
+      .bg-red-200 {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 202 202 / var(--tw-bg-opacity));
+      }
+
+      .uppercase {
+        text-transform: uppercase;
+      }
+
+      .hover\\:bg-red-100:hover {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 226 226 / var(--tw-bg-opacity));
+      }
+
+      .hover\\:bg-red-200:hover {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 202 202 / var(--tw-bg-opacity));
+      }
+    `)
+  })
+})
+
+it('should not generate duplicates', () => {
+  let config = {
+    content: [{ raw: html`<div class="uppercase"></div>` }],
+    safelist: [
+      'uppercase',
+      {
+        pattern: /bg-(red)-(100|200)/,
+        variants: ['hover'],
+      },
+      {
+        pattern: /bg-(red)-(100|200)/,
+        variants: ['hover'],
+      },
+      {
+        pattern: /bg-(red)-(100|200)/,
+        variants: ['hover'],
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchCss(css`
+      .bg-red-100 {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 226 226 / var(--tw-bg-opacity));
+      }
+
+      .bg-red-200 {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 202 202 / var(--tw-bg-opacity));
+      }
+
+      .uppercase {
+        text-transform: uppercase;
+      }
+
+      .hover\\:bg-red-100:hover {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 226 226 / var(--tw-bg-opacity));
+      }
+
+      .hover\\:bg-red-200:hover {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 202 202 / var(--tw-bg-opacity));
+      }
+    `)
+  })
+})
+
+it('should safelist when using a custom prefix', () => {
+  let config = {
+    prefix: 'tw-',
+    content: [{ raw: html`<div class="tw-uppercase"></div>` }],
+    safelist: [
+      {
+        pattern: /tw-bg-red-(100|200)/g,
+      },
+    ],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchCss(css`
+      .tw-bg-red-100 {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 226 226 / var(--tw-bg-opacity));
+      }
+
+      .tw-bg-red-200 {
+        --tw-bg-opacity: 1;
+        background-color: rgb(254 202 202 / var(--tw-bg-opacity));
+      }
+
+      .tw-uppercase {
+        text-transform: uppercase;
+      }
+    `)
+  })
+})
+
+it('should not safelist when an empty list is provided', () => {
+  let config = {
+    content: [{ raw: html`<div class="uppercase"></div>` }],
+    safelist: [],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchCss(css`
+      .uppercase {
+        text-transform: uppercase;
+      }
+    `)
+  })
+})
+
+it('should not safelist when an sparse/holey list is provided', () => {
+  let config = {
+    content: [{ raw: html`<div class="uppercase"></div>` }],
+    safelist: [, , ,],
+  }
+
+  return run('@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchCss(css`
+      .uppercase {
+        text-transform: uppercase;
+      }
+    `)
+  })
+})