From 3a8ea07f07d7d089bd3ff1901a005a1e597ba630 Mon Sep 17 00:00:00 2001 From: Brad Cornes Date: Wed, 26 May 2021 14:04:28 +0100 Subject: [PATCH] Add `transform` and `extract` APIs (#4469) * add `transform` and `extract` APIs * make svelte transform part of the transformer stuff --- src/jit/lib/expandTailwindAtRules.js | 64 ++++++--- src/lib/purgeUnusedStyles.js | 68 ++++++++-- tests/jit/custom-extractors.test.js | 55 ++++++++ tests/jit/custom-transformers.test.js | 93 +++++++++++++ tests/purgeUnusedStyles.test.js | 181 ++++++++++++++++++++++++++ 5 files changed, 432 insertions(+), 29 deletions(-) create mode 100644 tests/jit/custom-transformers.test.js diff --git a/src/jit/lib/expandTailwindAtRules.js b/src/jit/lib/expandTailwindAtRules.js index 0c9769d05668..81db4ee863ed 100644 --- a/src/jit/lib/expandTailwindAtRules.js +++ b/src/jit/lib/expandTailwindAtRules.js @@ -1,5 +1,3 @@ -import fs from 'fs' -import path from 'path' import * as sharedState from './sharedState' import { generateRules } from './generateRules' import bigSign from '../../util/bigSign' @@ -11,38 +9,63 @@ let contentMatchCache = sharedState.contentMatchCache const BROAD_MATCH_GLOBAL_REGEXP = /[^<>"'`\s]*[^<>"'`\s:]/g const INNER_MATCH_GLOBAL_REGEXP = /[^<>"'`\s.(){}[\]#=%]*[^<>"'`\s.(){}[\]#=%:]/g -function getDefaultExtractor(fileExtension) { - return function (content) { - if (fileExtension === 'svelte') { - content = content.replace(/(?:^|\s)class:/g, ' ') - } +const builtInExtractors = { + DEFAULT: (content) => { let broadMatches = content.match(BROAD_MATCH_GLOBAL_REGEXP) || [] let innerMatches = content.match(INNER_MATCH_GLOBAL_REGEXP) || [] return [...broadMatches, ...innerMatches] - } + }, +} + +const builtInTransformers = { + DEFAULT: (content) => content, + svelte: (content) => content.replace(/(?:^|\s)class:/g, ' '), } function getExtractor(tailwindConfig, fileExtension) { - const purgeOptions = tailwindConfig && tailwindConfig.purge && tailwindConfig.purge.options + let extractors = (tailwindConfig && tailwindConfig.purge && tailwindConfig.purge.extract) || {} + const purgeOptions = + (tailwindConfig && tailwindConfig.purge && tailwindConfig.purge.options) || {} - if (!fileExtension) { - return (purgeOptions && purgeOptions.defaultExtractor) || getDefaultExtractor() + if (typeof extractors === 'function') { + extractors = { + DEFAULT: extractors, + } } - - if (!purgeOptions) { - return getDefaultExtractor(fileExtension) + if (purgeOptions.defaultExtractor) { + extractors.DEFAULT = purgeOptions.defaultExtractor + } + for (let { extensions, extractor } of purgeOptions.extractors || []) { + for (let extension of extensions) { + extractors[extension] = extractor + } } - const fileSpecificExtractor = (purgeOptions.extractors || []).find((extractor) => - extractor.extensions.includes(fileExtension) + return ( + extractors[fileExtension] || + extractors.DEFAULT || + builtInExtractors[fileExtension] || + builtInExtractors.DEFAULT ) +} - if (fileSpecificExtractor) { - return fileSpecificExtractor.extractor +function getTransformer(tailwindConfig, fileExtension) { + let transformers = + (tailwindConfig && tailwindConfig.purge && tailwindConfig.purge.transform) || {} + + if (typeof transformers === 'function') { + transformers = { + DEFAULT: transformers, + } } - return purgeOptions.defaultExtractor || getDefaultExtractor(fileExtension) + return ( + transformers[fileExtension] || + transformers.DEFAULT || + builtInTransformers[fileExtension] || + builtInTransformers.DEFAULT + ) } // Scans template contents for possible classes. This is a hot path on initial build but @@ -141,8 +164,9 @@ export default function expandTailwindAtRules(context) { env.DEBUG && console.time('Reading changed files') for (let { content, extension } of context.changedContent) { + let transformer = getTransformer(context.tailwindConfig, extension) let extractor = getExtractor(context.tailwindConfig, extension) - getClassCandidates(content, extractor, contentMatchCache, candidates, seen) + getClassCandidates(transformer(content), extractor, contentMatchCache, candidates, seen) } // --- diff --git a/src/lib/purgeUnusedStyles.js b/src/lib/purgeUnusedStyles.js index 65cd3c99edb9..a9544dc7aeb8 100644 --- a/src/lib/purgeUnusedStyles.js +++ b/src/lib/purgeUnusedStyles.js @@ -34,6 +34,18 @@ export function tailwindExtractor(content) { return broadMatches.concat(broadMatchesWithoutTrailingSlash).concat(innerMatches) } +function getTransformer(config, fileExtension) { + let transformers = (config.purge && config.purge.transform) || {} + + if (typeof transformers === 'function') { + transformers = { + DEFAULT: transformers, + } + } + + return transformers[fileExtension] || transformers.DEFAULT || ((content) => content) +} + export default function purgeUnusedUtilities(config, configChanged, resolvedConfigPath) { const purgeEnabled = _.get( config, @@ -58,7 +70,50 @@ export default function purgeUnusedUtilities(config, configChanged, resolvedConf return removeTailwindMarkers } - const { defaultExtractor, ...purgeOptions } = config.purge.options || {} + const extractors = config.purge.extract || {} + const transformers = config.purge.transform || {} + let { defaultExtractor: originalDefaultExtractor, ...purgeOptions } = config.purge.options || {} + + if (!originalDefaultExtractor) { + originalDefaultExtractor = + typeof extractors === 'function' ? extractors : extractors.DEFAULT || tailwindExtractor + } + + const defaultExtractor = (content) => { + const preserved = originalDefaultExtractor(content) + + if (_.get(config, 'purge.preserveHtmlElements', true)) { + preserved.push(...htmlTags) + } + + return preserved + } + + // If `extractors` is a function then we don't have any file-specific extractors, + // only a default one. + let fileSpecificExtractors = typeof extractors === 'function' ? {} : extractors + + // PurgeCSS doesn't support "transformers," so we implement those using extractors. + // If we have a custom transformer for an extension, but not a matching extractor, + // then we need to create an extractor that we can augment later. + if (typeof transformers !== 'function') { + for (let [extension] of Object.entries(transformers)) { + if (!fileSpecificExtractors[extension]) { + fileSpecificExtractors[extension] = defaultExtractor + } + } + } + + // Augment file-specific extractors by running the transformer before we extract classes. + fileSpecificExtractors = Object.entries(fileSpecificExtractors).map(([extension, extractor]) => { + return { + extensions: [extension], + extractor: (content) => { + const transformer = getTransformer(config, extension) + return extractor(transformer(content)) + }, + } + }) return postcss([ function (css) { @@ -106,15 +161,10 @@ export default function purgeUnusedUtilities(config, configChanged, resolvedConf removeTailwindMarkers, purgecss({ defaultExtractor: (content) => { - const extractor = defaultExtractor || tailwindExtractor - const preserved = [...extractor(content)] - - if (_.get(config, 'purge.preserveHtmlElements', true)) { - preserved.push(...htmlTags) - } - - return preserved + const transformer = getTransformer(config) + return defaultExtractor(transformer(content)) }, + extractors: fileSpecificExtractors, ...purgeOptions, content: (Array.isArray(config.purge) ? config.purge diff --git a/tests/jit/custom-extractors.test.js b/tests/jit/custom-extractors.test.js index 09d0b0148055..9c9168e7ffec 100644 --- a/tests/jit/custom-extractors.test.js +++ b/tests/jit/custom-extractors.test.js @@ -65,3 +65,58 @@ test('extractors array', () => { expect(result.css).toMatchFormattedCss(expected) }) }) + +test('extract function', () => { + let config = { + mode: 'jit', + purge: { + content: [path.resolve(__dirname, './custom-extractors.test.html')], + extract: customExtractor, + }, + corePlugins: { preflight: false }, + theme: {}, + plugins: [], + } + + return run(css, config).then((result) => { + expect(result.css).toMatchFormattedCss(expected) + }) +}) + +test('extract.DEFAULT', () => { + let config = { + mode: 'jit', + purge: { + content: [path.resolve(__dirname, './custom-extractors.test.html')], + extract: { + DEFAULT: customExtractor, + }, + }, + corePlugins: { preflight: false }, + theme: {}, + plugins: [], + } + + return run(css, config).then((result) => { + expect(result.css).toMatchFormattedCss(expected) + }) +}) + +test('extract.{extension}', () => { + let config = { + purge: { + content: [path.resolve(__dirname, './custom-extractors.test.html')], + extract: { + html: customExtractor, + }, + }, + mode: 'jit', + corePlugins: { preflight: false }, + theme: {}, + plugins: [], + } + + return run(css, config).then((result) => { + expect(result.css).toMatchFormattedCss(expected) + }) +}) diff --git a/tests/jit/custom-transformers.test.js b/tests/jit/custom-transformers.test.js new file mode 100644 index 000000000000..b44d2a610988 --- /dev/null +++ b/tests/jit/custom-transformers.test.js @@ -0,0 +1,93 @@ +import postcss from 'postcss' +import path from 'path' + +function run(input, config = {}) { + jest.resetModules() + const tailwind = require('../../src/jit/index.js').default + return postcss(tailwind(config)).process(input, { + from: path.resolve(__filename), + }) +} + +function customTransformer(content) { + return content.replace(/uppercase/g, 'lowercase') +} + +const css = ` + @tailwind base; + @tailwind components; + @tailwind utilities; +` + +test('transform function', () => { + let config = { + mode: 'jit', + purge: { + content: [{ raw: '
' }], + transform: customTransformer, + }, + corePlugins: { preflight: false, borderColor: false, ringWidth: false, boxShadow: false }, + theme: {}, + plugins: [], + } + + return run(css, config).then((result) => { + expect(result.css).toMatchFormattedCss(` + .lowercase { + text-transform: lowercase; + } + `) + }) +}) + +test('transform.DEFAULT', () => { + let config = { + mode: 'jit', + purge: { + content: [{ raw: '
' }], + transform: { + DEFAULT: customTransformer, + }, + }, + corePlugins: { preflight: false, borderColor: false, ringWidth: false, boxShadow: false }, + theme: {}, + plugins: [], + } + + return run(css, config).then((result) => { + expect(result.css).toMatchFormattedCss(` + .lowercase { + text-transform: lowercase; + } + `) + }) +}) + +test('transform.{extension}', () => { + let config = { + mode: 'jit', + purge: { + content: [ + { raw: '
', extension: 'html' }, + { raw: '
', extension: 'php' }, + ], + transform: { + html: customTransformer, + }, + }, + corePlugins: { preflight: false, borderColor: false, ringWidth: false, boxShadow: false }, + theme: {}, + plugins: [], + } + + return run(css, config).then((result) => { + expect(result.css).toMatchFormattedCss(` + .uppercase { + text-transform: uppercase; + } + .lowercase { + text-transform: lowercase; + } + `) + }) +}) diff --git a/tests/purgeUnusedStyles.test.js b/tests/purgeUnusedStyles.test.js index 59724b535d90..1cae3ab773d2 100644 --- a/tests/purgeUnusedStyles.test.js +++ b/tests/purgeUnusedStyles.test.js @@ -663,6 +663,187 @@ test('element selectors are preserved by default', () => { ) }) +test('custom default extractor', () => { + return inProduction( + suppressConsoleLogs(() => { + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false }, + purge: { + content: [ + path.resolve(`${__dirname}/fixtures/**/*.html`), + { raw: '
', extension: 'php' }, + ], + extract: () => [], + mode: 'all', + preserveHtmlElements: false, + options: { + keyframes: true, + }, + }, + }), + ]) + .process(input, { from: withTestName(inputPath) }) + .then((result) => { + expect(result.css).toMatchFormattedCss('') + }) + }) + ) +}) + +test('custom extension-specific extractor', () => { + return inProduction( + suppressConsoleLogs(() => { + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false }, + purge: { + content: [ + path.resolve(`${__dirname}/fixtures/**/*.html`), + { raw: '
', extension: 'html' }, + { raw: '
', extension: 'php' }, + ], + extract: { + html: () => [], + }, + mode: 'all', + preserveHtmlElements: false, + options: { + keyframes: true, + }, + }, + }), + ]) + .process(input, { from: withTestName(inputPath) }) + .then((result) => { + expect(result.css).toMatchFormattedCss(` + .uppercase { + text-transform: uppercase; + } + `) + }) + }) + ) +}) + +test('custom default transformer', () => { + return inProduction( + suppressConsoleLogs(() => { + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false }, + purge: { + content: [{ raw: '
', extension: 'html' }], + transform: (content) => content.replace(/uppercase/g, 'lowercase'), + mode: 'all', + preserveHtmlElements: false, + options: { + keyframes: true, + }, + }, + }), + ]) + .process(input, { from: withTestName(inputPath) }) + .then((result) => { + expect(result.css).toMatchFormattedCss(` + .lowercase { + text-transform: lowercase; + } + `) + }) + }) + ) +}) + +test('custom explicit default transformer', () => { + return inProduction( + suppressConsoleLogs(() => { + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false }, + purge: { + content: [{ raw: '
', extension: 'html' }], + transform: { + DEFAULT: (content) => content.replace(/uppercase/g, 'lowercase'), + }, + mode: 'all', + preserveHtmlElements: false, + options: { + keyframes: true, + }, + }, + }), + ]) + .process(input, { from: withTestName(inputPath) }) + .then((result) => { + expect(result.css).toMatchFormattedCss(` + .lowercase { + text-transform: lowercase; + } + `) + }) + }) + ) +}) + +test('custom extension-specific transformer', () => { + return inProduction( + suppressConsoleLogs(() => { + const inputPath = path.resolve(`${__dirname}/fixtures/tailwind-input.css`) + const input = fs.readFileSync(inputPath, 'utf8') + + return postcss([ + tailwind({ + ...config, + corePlugins: { preflight: false, ringWidth: false, boxShadow: false, borderColor: false }, + purge: { + content: [ + { raw: '
', extension: 'html' }, + { raw: '
', extension: 'php' }, + ], + transform: { + html: (content) => content.replace(/uppercase/g, 'lowercase'), + }, + mode: 'all', + preserveHtmlElements: false, + options: { + keyframes: true, + }, + }, + }), + ]) + .process(input, { from: withTestName(inputPath) }) + .then((result) => { + expect(result.css).toMatchFormattedCss(` + .uppercase { + text-transform: uppercase; + } + + .lowercase { + text-transform: lowercase; + } + `) + }) + }) + ) +}) + test('element selectors are preserved even when defaultExtractor is overridden', () => { return inProduction( suppressConsoleLogs(() => {