Add transform and extract APIs (#4469)
* add `transform` and `extract` APIs * make svelte transform part of the transformer stuff
This commit is contained in:
parent
e6395c817e
commit
6bf6738683
5 changed files with 433 additions and 30 deletions
|
|
@ -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.defaultExtractor) {
|
||||
extractors.DEFAULT = purgeOptions.defaultExtractor
|
||||
}
|
||||
for (let { extensions, extractor } of purgeOptions.extractors || []) {
|
||||
for (let extension of extensions) {
|
||||
extractors[extension] = extractor
|
||||
}
|
||||
}
|
||||
|
||||
if (!purgeOptions) {
|
||||
return getDefaultExtractor(fileExtension)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
tests/jit/custom-transformers.test.js
Normal file
93
tests/jit/custom-transformers.test.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
|
@ -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: '<div class="uppercase"></div>', 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: '<div class="lowercase"></div>', extension: 'html' },
|
||||
{ raw: '<div class="uppercase"></div>', 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: '<div class="uppercase"></div>', 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: '<div class="uppercase"></div>', 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: '<div class="uppercase"></div>', extension: 'html' },
|
||||
{ raw: '<div class="uppercase"></div>', 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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue