Skip to content

Commit 8518fee

Browse files
authored
implement purge safelist (#4580)
* fix --help output in tests * add tests to ensure we can use `purge.safelist` * implement the `purge.safelist` for strings * proxy `purge.safelist` to `purge.options.safelist` This allows us to have a similar API in `AOT` and `JIT` mode. * only proxy `purge.safelist` to `purge.options.safelist` if `purge.options.safelist` doesn't exists yet.
1 parent 3569d49 commit 8518fee

File tree

8 files changed

+188
-2
lines changed

8 files changed

+188
-2
lines changed

integrations/tailwindcss-cli/tests/cli.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ describe('Build command', () => {
172172
-o, --output Output file
173173
-w, --watch Watch for changes and rebuild as needed
174174
--jit Build using JIT mode
175-
--files Template files to scan for class names
175+
--purge Content paths to use for removing unused classes
176176
--postcss Load custom PostCSS configuration
177177
-m, --minify Minify the output
178178
-c, --config Path to a custom config file

integrations/tailwindcss-cli/tests/integration.test.js

+52
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,58 @@ describe('static build', () => {
2525
`
2626
)
2727
})
28+
29+
it('should safelist a list of classes to always include', async () => {
30+
await writeInputFile('index.html', html`<div class="font-bold"></div>`)
31+
await writeInputFile(
32+
'../tailwind.config.js',
33+
javascript`
34+
module.exports = {
35+
purge: {
36+
content: ['./src/index.html'],
37+
safelist: ['bg-red-500','bg-red-600']
38+
},
39+
mode: 'jit',
40+
darkMode: false, // or 'media' or 'class'
41+
theme: {
42+
extend: {
43+
},
44+
},
45+
variants: {
46+
extend: {},
47+
},
48+
corePlugins: {
49+
preflight: false,
50+
},
51+
plugins: [],
52+
}
53+
`
54+
)
55+
56+
$('node ../../lib/cli.js -i ./src/index.css -o ./dist/main.css', {
57+
env: { NODE_ENV: 'production' },
58+
})
59+
60+
await waitForOutputFileCreation('main.css')
61+
62+
expect(await readOutputFile('main.css')).toIncludeCss(
63+
css`
64+
.bg-red-500 {
65+
--tw-bg-opacity: 1;
66+
background-color: rgba(239, 68, 68, var(--tw-bg-opacity));
67+
}
68+
69+
.bg-red-600 {
70+
--tw-bg-opacity: 1;
71+
background-color: rgba(220, 38, 38, var(--tw-bg-opacity));
72+
}
73+
74+
.font-bold {
75+
font-weight: 700;
76+
}
77+
`
78+
)
79+
})
2880
})
2981

3082
describe('watcher', () => {

integrations/webpack-5/tests/integration.test.js

+52
Original file line numberDiff line numberDiff line change
@@ -227,4 +227,56 @@ describe.each([{ TAILWIND_MODE: 'watch' }, { TAILWIND_MODE: undefined }])('watch
227227

228228
return runningProcess.stop()
229229
})
230+
231+
it('should safelist a list of classes to always include', async () => {
232+
await writeInputFile('index.html', html`<div class="font-bold"></div>`)
233+
await writeInputFile(
234+
'../tailwind.config.js',
235+
javascript`
236+
module.exports = {
237+
purge: {
238+
content: ['./src/index.html'],
239+
safelist: ['bg-red-500','bg-red-600']
240+
},
241+
mode: 'jit',
242+
darkMode: false, // or 'media' or 'class'
243+
theme: {
244+
extend: {
245+
},
246+
},
247+
variants: {
248+
extend: {},
249+
},
250+
corePlugins: {
251+
preflight: false,
252+
},
253+
plugins: [],
254+
}
255+
`
256+
)
257+
258+
let runningProcess = $('webpack --mode=development --watch', { env })
259+
260+
await waitForOutputFileCreation('main.css')
261+
262+
expect(await readOutputFile('main.css')).toIncludeCss(
263+
css`
264+
.bg-red-500 {
265+
--tw-bg-opacity: 1;
266+
background-color: rgba(239, 68, 68, var(--tw-bg-opacity));
267+
}
268+
269+
.bg-red-600 {
270+
--tw-bg-opacity: 1;
271+
background-color: rgba(220, 38, 38, var(--tw-bg-opacity));
272+
}
273+
274+
.font-bold {
275+
font-weight: 700;
276+
}
277+
`
278+
)
279+
280+
return runningProcess.stop()
281+
})
230282
})

src/cli.js

+19-1
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,25 @@ async function build() {
356356
}
357357

358358
function extractContent(config) {
359-
return Array.isArray(config.purge) ? config.purge : config.purge.content
359+
let content = Array.isArray(config.purge) ? config.purge : config.purge.content
360+
361+
return content.concat(
362+
(config.purge?.safelist ?? []).map((content) => {
363+
if (typeof content === 'string') {
364+
return { raw: content, extension: 'html' }
365+
}
366+
367+
if (content instanceof RegExp) {
368+
throw new Error(
369+
"Values inside 'purge.safelist' can only be of type 'string', found 'regex'."
370+
)
371+
}
372+
373+
throw new Error(
374+
`Values inside 'purge.safelist' can only be of type 'string', found '${typeof content}'.`
375+
)
376+
})
377+
)
360378
}
361379

362380
function extractFileGlobs(config) {

src/jit/lib/setupTrackingContext.js

+17
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ function resolvedChangedContent(context, candidateFiles, fileModifiedMap) {
8888
: context.tailwindConfig.purge.content
8989
)
9090
.filter((item) => typeof item.raw === 'string')
91+
.concat(
92+
(context.tailwindConfig.purge?.safelist ?? []).map((content) => {
93+
if (typeof content === 'string') {
94+
return { raw: content, extension: 'html' }
95+
}
96+
97+
if (content instanceof RegExp) {
98+
throw new Error(
99+
"Values inside 'purge.safelist' can only be of type 'string', found 'regex'."
100+
)
101+
}
102+
103+
throw new Error(
104+
`Values inside 'purge.safelist' can only be of type 'string', found '${typeof content}'.`
105+
)
106+
})
107+
)
91108
.map(({ raw, extension }) => ({ content: raw, extension }))
92109

93110
for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) {

src/jit/lib/setupWatchingContext.js

+17
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,23 @@ function resolvedChangedContent(context, candidateFiles) {
235235
: context.tailwindConfig.purge.content
236236
)
237237
.filter((item) => typeof item.raw === 'string')
238+
.concat(
239+
(context.tailwindConfig.purge?.safelist ?? []).map((content) => {
240+
if (typeof content === 'string') {
241+
return { raw: content, extension: 'html' }
242+
}
243+
244+
if (content instanceof RegExp) {
245+
throw new Error(
246+
"Values inside 'purge.safelist' can only be of type 'string', found 'regex'."
247+
)
248+
}
249+
250+
throw new Error(
251+
`Values inside 'purge.safelist' can only be of type 'string', found '${typeof content}'.`
252+
)
253+
})
254+
)
238255
.map(({ raw, extension }) => ({ content: raw, extension }))
239256

240257
for (let changedFile of resolveChangedFiles(context, candidateFiles)) {

src/lib/purgeUnusedStyles.js

+4
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export default function purgeUnusedUtilities(
8181
const transformers = config.purge.transform || {}
8282
let { defaultExtractor: originalDefaultExtractor, ...purgeOptions } = config.purge.options || {}
8383

84+
if (config.purge?.safelist && !purgeOptions.hasOwnProperty('safelist')) {
85+
purgeOptions.safelist = config.purge.safelist
86+
}
87+
8488
if (!originalDefaultExtractor) {
8589
originalDefaultExtractor =
8690
typeof extractors === 'function' ? extractors : extractors.DEFAULT || tailwindExtractor

tests/purgeUnusedStyles.test.js

+26
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,32 @@ test(
579579
})
580580
)
581581

582+
test(
583+
'proxying purge.safelist to purge.options.safelist works',
584+
suppressConsoleLogs(() => {
585+
const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`)
586+
const input = fs.readFileSync(inputPath, 'utf8')
587+
588+
return postcss([
589+
tailwind({
590+
...config,
591+
purge: {
592+
enabled: true,
593+
safelist: ['md:bg-green-500'],
594+
options: {
595+
content: [path.resolve(`${__dirname}/fixtures/**/*.html`)],
596+
},
597+
},
598+
}),
599+
])
600+
.process(input, { from: withTestName(inputPath) })
601+
.then((result) => {
602+
expect(result.css).toContain('.md\\:bg-green-500')
603+
assertPurged(result)
604+
})
605+
})
606+
)
607+
582608
test(
583609
'can purge all CSS, not just Tailwind classes',
584610
suppressConsoleLogs(() => {

0 commit comments

Comments
 (0)