Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add transform and extract APIs #4469

Merged
merged 2 commits into from May 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
64 changes: 44 additions & 20 deletions 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'
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

// ---
Expand Down
68 changes: 59 additions & 9 deletions src/lib/purgeUnusedStyles.js
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions tests/jit/custom-extractors.test.js
Expand Up @@ -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)
})
})
93 changes: 93 additions & 0 deletions 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: '<div class="uppercase"></div>' }],
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: '<div class="uppercase"></div>' }],
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: '<div class="uppercase"></div>', extension: 'html' },
{ raw: '<div class="uppercase"></div>', 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;
}
`)
})
})