diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 6f563efb316d..ed7c974d4940 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 f0ea6ab2616d..9b1e71ec071c 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 4e9d772b8557..1eeeb11cf2eb 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 2bb0548bd0f9..f67b3fd581a8 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}` } diff --git a/tests/safelist.test.js b/tests/safelist.test.js new file mode 100644 index 000000000000..6325cfad3803 --- /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`
` }], + } + + 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`` }], + 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`` }], + 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`` }], + 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`` }], + 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`` }], + 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`` }], + safelist: [, , ,], + } + + return run('@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchCss(css` + .uppercase { + text-transform: uppercase; + } + `) + }) +})