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:
parent
14d49a9fd5
commit
6c0b7bc8b2
5 changed files with 295 additions and 6 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
196
tests/safelist.test.js
Normal 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;
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue