Pattern safelisting (#5511)

* splitup nameClass functionality

We want to separate the logic of the formatting the class and of turning
it into a class with a `.` and escaped values.

* collect base classList

* implement `safelist` where you can use regex patterns
This commit is contained in:
Robin Malfait 2021-09-16 14:47:41 +02:00 • committed by GitHub
parent 14d49a9fd5
commit 6c0b7bc8b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 295 additions and 6 deletions

View file

@ -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(

View file

@ -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)) {

View file

@ -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)) {

View file

@ -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}`
}

196
tests/safelist.test.js Normal file
View file

@ -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;
}
`)
})
})