import fs from 'fs' import path from 'path' import postcss from 'postcss' import tailwind from '../src/index' import { tailwindExtractor } from '../src/lib/purgeUnusedStyles' import defaultConfig from '../stubs/defaultConfig.stub.js' function suppressConsoleLogs(cb, type = 'warn') { return () => { const spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()) const promise = new Promise((resolve, reject) => { Promise.resolve(cb()).then(resolve, reject) }) promise.then(spy.mockRestore, spy.mockRestore) return promise } } function extractRules(root) { let rules = [] root.walkRules((r) => { rules = rules.concat(r.selectors) }) return rules } async function inProduction(callback) { const OLD_NODE_ENV = process.env.NODE_ENV process.env.NODE_ENV = 'production' const result = await callback() process.env.NODE_ENV = OLD_NODE_ENV return result } const config = { ...defaultConfig, theme: { extend: { colors: { 'black!': '#000', }, spacing: { 1.5: '0.375rem', '(1/2+8)': 'calc(50% + 2rem)', }, minHeight: { '(screen-4)': 'calc(100vh - 1rem)', }, fontFamily: { '%#$@': 'Comic Sans', }, }, }, } delete config.presets function assertPurged(result) { expect(result.css).not.toContain('.bg-red-600') expect(result.css).not.toContain('.w-1\\/3') expect(result.css).not.toContain('.flex') expect(result.css).not.toContain('.font-sans') expect(result.css).not.toContain('.text-right') expect(result.css).not.toContain('.px-4') expect(result.css).not.toContain('.h-full') expect(result.css).toContain('.bg-red-500') expect(result.css).toContain('.md\\:bg-blue-300') expect(result.css).toContain('.w-1\\/2') expect(result.css).toContain('.block') expect(result.css).toContain('.md\\:flow-root') expect(result.css).toContain('.h-screen') expect(result.css).toContain('.min-h-\\(screen-4\\)') expect(result.css).toContain('.bg-black\\!') expect(result.css).toContain('.font-\\%\\#\\$\\@') expect(result.css).toContain('.w-\\(1\\/2\\+8\\)') expect(result.css).toContain('.inline-grid') expect(result.css).toContain('.grid-cols-3') expect(result.css).toContain('.px-1\\.5') expect(result.css).toContain('.col-span-2') expect(result.css).toContain('.col-span-1') expect(result.css).toContain('.text-center') expect(result.css).toContain('.flow-root') expect(result.css).toContain('.text-green-700') expect(result.css).toContain('.bg-green-100') expect(result.css).toContain('.text-left') expect(result.css).toContain('.font-mono') expect(result.css).toContain('.col-span-4') expect(result.css).toContain('.tracking-tight') expect(result.css).toContain('.whitespace-nowrap') } test('purges unused classes', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }), ]) .process(input, { from: inputPath }) .then((result) => { assertPurged(result) }) }) ) }) test('custom css is not purged by default', () => { return inProduction( suppressConsoleLogs(() => { return postcss([ tailwind({ ...config, purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }), ]) .process( ` @tailwind base; @tailwind components; @tailwind utilities; .example { @apply font-bold; color: theme('colors.red.500'); } `, { from: null } ) .then((result) => { const rules = extractRules(result.root) assertPurged(result) expect(rules).toContain('.example') }) }) ) }) test('custom css that uses @responsive is not purged by default', () => { return inProduction( suppressConsoleLogs(() => { return postcss([ tailwind({ ...config, purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }), ]) .process( ` @tailwind base; @tailwind components; @tailwind utilities; @responsive { .example { @apply font-bold; color: theme('colors.red.500'); } } `, { from: null } ) .then((result) => { const rules = extractRules(result.root) assertPurged(result) expect(rules).toContain('.example') }) }) ) }) test('custom css in a layer is purged by default when using layers mode', () => { return inProduction( suppressConsoleLogs(() => { return postcss([ tailwind({ ...config, future: { purgeLayersByDefault: true, }, purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }), ]) .process( ` @tailwind base; @tailwind components; @layer components { .example { @apply font-bold; color: theme('colors.red.500'); } } @tailwind utilities; `, { from: null } ) .then((result) => { const rules = extractRules(result.root) assertPurged(result) expect(rules).not.toContain('.example') }) }) ) }) test('custom css in a layer in a @responsive at-rule is purged by default', () => { return inProduction( suppressConsoleLogs(() => { return postcss([ tailwind({ ...config, future: { purgeLayersByDefault: true, }, purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }), ]) .process( ` @tailwind base; @tailwind components; @layer components { @responsive { .example { @apply font-bold; color: theme('colors.red.500'); } } } @tailwind utilities; `, { from: null } ) .then((result) => { const rules = extractRules(result.root) assertPurged(result) expect(rules).not.toContain('.example') }) }) ) }) test('purges unused classes with important string', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, important: '#tailwind', purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }), ]) .process(input, { from: inputPath }) .then((result) => { assertPurged(result) }) }) ) }) test('mode must be a valid value', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return expect( postcss([ tailwind({ ...config, purge: { mode: 'poop', content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }, }), ]).process(input, { from: inputPath }) ).rejects.toThrow() }) ) }) test('components are purged by default in layers mode', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, future: { purgeLayersByDefault: true, }, purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }), ]) .process(input, { from: inputPath }) .then((result) => { expect(result.css).not.toContain('.container') assertPurged(result) }) }) ) }) test('you can specify which layers to purge', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, future: { purgeLayersByDefault: true, }, purge: { mode: 'layers', layers: ['utilities'], content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }, }), ]) .process(input, { from: inputPath }) .then((result) => { const rules = extractRules(result.root) expect(rules).toContain('optgroup') expect(rules).toContain('.container') assertPurged(result) }) }) ) }) test('you can purge just base and component layers (but why)', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, future: { purgeLayersByDefault: true, }, purge: { mode: 'layers', layers: ['base', 'components'], content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }, }), ]) .process(input, { from: inputPath }) .then((result) => { const rules = extractRules(result.root) expect(rules).not.toContain('[type="checkbox"]') expect(rules).not.toContain('.container') expect(rules).toContain('.float-left') expect(rules).toContain('.md\\:bg-red-500') expect(rules).toContain('.lg\\:appearance-none') }) }) ) }) test('extra purgecss control comments can be added manually', () => { return inProduction( suppressConsoleLogs(() => { const input = ` @tailwind base; /* purgecss start ignore */ .btn { background: red; } /* purgecss end ignore */ @tailwind components; @tailwind utilities; ` return postcss([ tailwind({ ...config, purge: { layers: ['utilities'], content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }, }), ]) .process(input, { from: null }) .then((result) => { const rules = extractRules(result.root) expect(rules).toContain('.btn') expect(rules).toContain('.container') assertPurged(result) }) }) ) }) test( 'does not purge except in production', suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...defaultConfig, purge: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }), ]) .process(input, { from: inputPath }) .then((result) => { const expected = fs.readFileSync( path.resolve(`${__dirname}/fixtures/tailwind-output.css`), 'utf8' ) expect(result.css).toMatchCss(expected) }) }) ) test('does not purge if the array is empty', () => { return inProduction( suppressConsoleLogs(() => { const OLD_NODE_ENV = process.env.NODE_ENV process.env.NODE_ENV = 'production' const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...defaultConfig, purge: [], }), ]) .process(input, { from: inputPath }) .then((result) => { process.env.NODE_ENV = OLD_NODE_ENV const expected = fs.readFileSync( path.resolve(`${__dirname}/fixtures/tailwind-output.css`), 'utf8' ) expect(result.css).toMatchCss(expected) }) }) ) }) test('does not purge if explicitly disabled', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...defaultConfig, purge: { enabled: false }, }), ]) .process(input, { from: inputPath }) .then((result) => { const expected = fs.readFileSync( path.resolve(`${__dirname}/fixtures/tailwind-output.css`), 'utf8' ) expect(result.css).toMatchCss(expected) }) }) ) }) test('does not purge if purge is simply false', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...defaultConfig, purge: false, }), ]) .process(input, { from: inputPath }) .then((result) => { const expected = fs.readFileSync( path.resolve(`${__dirname}/fixtures/tailwind-output.css`), 'utf8' ) expect(result.css).toMatchCss(expected) }) }) ) }) test('purges outside of production if explicitly enabled', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, purge: { enabled: true, content: [path.resolve(`${__dirname}/fixtures/**/*.html`)] }, }), ]) .process(input, { from: inputPath }) .then((result) => { assertPurged(result) }) }) ) }) test( 'purgecss options can be provided', suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, purge: { enabled: true, options: { content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], safelist: ['md:bg-green-500'], }, }, }), ]) .process(input, { from: inputPath }) .then((result) => { expect(result.css).toContain('.md\\:bg-green-500') assertPurged(result) }) }) ) test( 'can purge all CSS, not just Tailwind classes', suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, purge: { enabled: true, mode: 'all', content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], }, }), function (css) { // Remove any comments to avoid accidentally asserting against them // instead of against real CSS rules. css.walkComments((c) => c.remove()) }, ]) .process(input, { from: inputPath }) .then((result) => { expect(result.css).toContain('html') expect(result.css).toContain('body') expect(result.css).toContain('samp') expect(result.css).not.toContain('.example') expect(result.css).not.toContain('.sm\\:example') assertPurged(result) }) }) ) test('element selectors are preserved by default', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, purge: { content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], mode: 'all', }, }), ]) .process(input, { from: inputPath }) .then((result) => { const rules = extractRules(result.root) ;[ 'a', 'blockquote', 'body', 'code', 'fieldset', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'html', 'img', 'kbd', 'ol', 'p', 'pre', 'strong', 'sup', 'table', 'ul', ].forEach((e) => expect(rules).toContain(e)) assertPurged(result) }) }) ) }) test('element selectors are preserved even when defaultExtractor is overridden', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, purge: { content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], mode: 'all', preserveHtmlElements: true, options: { defaultExtractor: tailwindExtractor, }, }, }), ]) .process(input, { from: inputPath }) .then((result) => { const rules = extractRules(result.root) ;[ 'a', 'blockquote', 'body', 'code', 'fieldset', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'html', 'img', 'kbd', 'ol', 'p', 'pre', 'strong', 'sup', 'table', 'ul', ].forEach((e) => expect(rules).toContain(e)) assertPurged(result) }) }) ) }) test('preserving element selectors can be disabled', () => { return inProduction( suppressConsoleLogs(() => { const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) const input = fs.readFileSync(inputPath, 'utf8') return postcss([ tailwind({ ...config, purge: { content: [path.resolve(`${__dirname}/fixtures/**/*.html`)], mode: 'all', preserveHtmlElements: false, }, }), ]) .process(input, { from: inputPath }) .then((result) => { const rules = extractRules(result.root) ;[ 'blockquote', 'code', 'em', 'fieldset', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'kbd', 'li', 'ol', 'pre', 'strong', 'sup', 'table', 'ul', ].forEach((e) => expect(rules).not.toContain(e)) assertPurged(result) }) }) ) })