diff --git a/integrations/tailwindcss-cli/tests/cli.test.js b/integrations/tailwindcss-cli/tests/cli.test.js index fc065a57d..004df28b4 100644 --- a/integrations/tailwindcss-cli/tests/cli.test.js +++ b/integrations/tailwindcss-cli/tests/cli.test.js @@ -172,7 +172,7 @@ describe('Build command', () => { -o, --output Output file -w, --watch Watch for changes and rebuild as needed --jit Build using JIT mode - --files Template files to scan for class names + --purge Content paths to use for removing unused classes --postcss Load custom PostCSS configuration -m, --minify Minify the output -c, --config Path to a custom config file diff --git a/integrations/tailwindcss-cli/tests/integration.test.js b/integrations/tailwindcss-cli/tests/integration.test.js index edca84be3..5a735ffac 100644 --- a/integrations/tailwindcss-cli/tests/integration.test.js +++ b/integrations/tailwindcss-cli/tests/integration.test.js @@ -25,6 +25,58 @@ describe('static build', () => { ` ) }) + + it('should safelist a list of classes to always include', async () => { + await writeInputFile('index.html', html`
`) + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + purge: { + content: ['./src/index.html'], + safelist: ['bg-red-500','bg-red-600'] + }, + mode: 'jit', + darkMode: false, // or 'media' or 'class' + theme: { + extend: { + }, + }, + variants: { + extend: {}, + }, + corePlugins: { + preflight: false, + }, + plugins: [], + } + ` + ) + + $('node ../../lib/cli.js -i ./src/index.css -o ./dist/main.css', { + env: { NODE_ENV: 'production' }, + }) + + await waitForOutputFileCreation('main.css') + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .bg-red-500 { + --tw-bg-opacity: 1; + background-color: rgba(239, 68, 68, var(--tw-bg-opacity)); + } + + .bg-red-600 { + --tw-bg-opacity: 1; + background-color: rgba(220, 38, 38, var(--tw-bg-opacity)); + } + + .font-bold { + font-weight: 700; + } + ` + ) + }) }) describe('watcher', () => { diff --git a/integrations/webpack-5/tests/integration.test.js b/integrations/webpack-5/tests/integration.test.js index c66b62912..deaaa12d0 100644 --- a/integrations/webpack-5/tests/integration.test.js +++ b/integrations/webpack-5/tests/integration.test.js @@ -227,4 +227,56 @@ describe.each([{ TAILWIND_MODE: 'watch' }, { TAILWIND_MODE: undefined }])('watch return runningProcess.stop() }) + + it('should safelist a list of classes to always include', async () => { + await writeInputFile('index.html', html`
`) + await writeInputFile( + '../tailwind.config.js', + javascript` + module.exports = { + purge: { + content: ['./src/index.html'], + safelist: ['bg-red-500','bg-red-600'] + }, + mode: 'jit', + darkMode: false, // or 'media' or 'class' + theme: { + extend: { + }, + }, + variants: { + extend: {}, + }, + corePlugins: { + preflight: false, + }, + plugins: [], + } + ` + ) + + let runningProcess = $('webpack --mode=development --watch', { env }) + + await waitForOutputFileCreation('main.css') + + expect(await readOutputFile('main.css')).toIncludeCss( + css` + .bg-red-500 { + --tw-bg-opacity: 1; + background-color: rgba(239, 68, 68, var(--tw-bg-opacity)); + } + + .bg-red-600 { + --tw-bg-opacity: 1; + background-color: rgba(220, 38, 38, var(--tw-bg-opacity)); + } + + .font-bold { + font-weight: 700; + } + ` + ) + + return runningProcess.stop() + }) }) diff --git a/src/cli.js b/src/cli.js index 85810df64..f93b5da80 100644 --- a/src/cli.js +++ b/src/cli.js @@ -356,7 +356,25 @@ async function build() { } function extractContent(config) { - return Array.isArray(config.purge) ? config.purge : config.purge.content + let content = Array.isArray(config.purge) ? config.purge : config.purge.content + + return content.concat( + (config.purge?.safelist ?? []).map((content) => { + if (typeof content === 'string') { + return { raw: content, extension: 'html' } + } + + if (content instanceof RegExp) { + throw new Error( + "Values inside 'purge.safelist' can only be of type 'string', found 'regex'." + ) + } + + throw new Error( + `Values inside 'purge.safelist' can only be of type 'string', found '${typeof content}'.` + ) + }) + ) } function extractFileGlobs(config) { diff --git a/src/jit/lib/setupTrackingContext.js b/src/jit/lib/setupTrackingContext.js index 416157013..7d5f7d2f5 100644 --- a/src/jit/lib/setupTrackingContext.js +++ b/src/jit/lib/setupTrackingContext.js @@ -88,6 +88,23 @@ function resolvedChangedContent(context, candidateFiles, fileModifiedMap) { : context.tailwindConfig.purge.content ) .filter((item) => typeof item.raw === 'string') + .concat( + (context.tailwindConfig.purge?.safelist ?? []).map((content) => { + if (typeof content === 'string') { + return { raw: content, extension: 'html' } + } + + if (content instanceof RegExp) { + throw new Error( + "Values inside 'purge.safelist' can only be of type 'string', found 'regex'." + ) + } + + throw new Error( + `Values inside 'purge.safelist' can only be of type 'string', found '${typeof content}'.` + ) + }) + ) .map(({ raw, extension }) => ({ content: raw, extension })) for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) { diff --git a/src/jit/lib/setupWatchingContext.js b/src/jit/lib/setupWatchingContext.js index 783be3580..84c661c52 100644 --- a/src/jit/lib/setupWatchingContext.js +++ b/src/jit/lib/setupWatchingContext.js @@ -235,6 +235,23 @@ function resolvedChangedContent(context, candidateFiles) { : context.tailwindConfig.purge.content ) .filter((item) => typeof item.raw === 'string') + .concat( + (context.tailwindConfig.purge?.safelist ?? []).map((content) => { + if (typeof content === 'string') { + return { raw: content, extension: 'html' } + } + + if (content instanceof RegExp) { + throw new Error( + "Values inside 'purge.safelist' can only be of type 'string', found 'regex'." + ) + } + + throw new Error( + `Values inside 'purge.safelist' can only be of type 'string', found '${typeof content}'.` + ) + }) + ) .map(({ raw, extension }) => ({ content: raw, extension })) for (let changedFile of resolveChangedFiles(context, candidateFiles)) { diff --git a/src/lib/purgeUnusedStyles.js b/src/lib/purgeUnusedStyles.js index bc8cc64b2..76bee22e1 100644 --- a/src/lib/purgeUnusedStyles.js +++ b/src/lib/purgeUnusedStyles.js @@ -81,6 +81,10 @@ export default function purgeUnusedUtilities( const transformers = config.purge.transform || {} let { defaultExtractor: originalDefaultExtractor, ...purgeOptions } = config.purge.options || {} + if (config.purge?.safelist && !purgeOptions.hasOwnProperty('safelist')) { + purgeOptions.safelist = config.purge.safelist + } + if (!originalDefaultExtractor) { originalDefaultExtractor = typeof extractors === 'function' ? extractors : extractors.DEFAULT || tailwindExtractor diff --git a/tests/purgeUnusedStyles.test.js b/tests/purgeUnusedStyles.test.js index 1cae3ab77..ad249233a 100644 --- a/tests/purgeUnusedStyles.test.js +++ b/tests/purgeUnusedStyles.test.js @@ -579,6 +579,32 @@ test( }) ) +test( + 'proxying purge.safelist to purge.options.safelist works', + suppressConsoleLogs(() => { + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + purge: { + enabled: true, + safelist: ['md:bg-green-500'], + options: { + content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], + }, + }, + }), + ]) + .process(input, { from: withTestName(inputPath) }) + .then((result) => { + expect(result.css).toContain('.md\\:bg-green-500') + assertPurged(result) + }) + }) +) + test( 'can purge all CSS, not just Tailwind classes', suppressConsoleLogs(() => {