Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions src/lib/setupContextUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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, [])
}
Expand Down Expand Up @@ -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, [])
}
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/lib/setupTrackingContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
1 change: 1 addition & 0 deletions src/lib/setupWatchingContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
12 changes: 8 additions & 4 deletions src/util/nameClass.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 196 additions & 0 deletions tests/safelist.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
`)
})
})