From 11bfa0a9bdacf32ce9310f7202255f9893b14339 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Sep 2021 12:39:41 +0200 Subject: [PATCH 1/7] Detect ambiguity in arbitrary values (#5634) * detect ambiguity in arbitrary values * update warning message --- src/lib/generateRules.js | 83 ++++++++++++++++++++++++++++++++-- tests/arbitrary-values.test.js | 12 +++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 265e2482e0e8..13ec474803cb 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -4,6 +4,7 @@ import parseObjectStyles from '../util/parseObjectStyles' import isPlainObject from '../util/isPlainObject' import prefixSelector from '../util/prefixSelector' import { updateAllClasses } from '../util/pluginUtils' +import log from '../util/log' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value @@ -225,15 +226,19 @@ function* resolveMatches(candidate, context) { for (let matchedPlugins of resolveMatchedPlugins(classCandidate, context)) { let matches = [] + let typesByMatches = new Map() + let [plugins, modifier] = matchedPlugins let isOnlyPlugin = plugins.length === 1 for (let [sort, plugin] of plugins) { + let matchesPerPlugin = [] + if (typeof plugin === 'function') { for (let ruleSet of [].concat(plugin(modifier, { isOnlyPlugin }))) { let [rules, options] = parseRules(ruleSet, context.postCssNodeCache) for (let rule of rules) { - matches.push([{ ...sort, options: { ...sort.options, ...options } }, rule]) + matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule]) } } } @@ -242,12 +247,80 @@ function* resolveMatches(candidate, context) { let ruleSet = plugin let [rules, options] = parseRules(ruleSet, context.postCssNodeCache) for (let rule of rules) { - matches.push([{ ...sort, options: { ...sort.options, ...options } }, rule]) + matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule]) } } + + if (matchesPerPlugin.length > 0) { + typesByMatches.set(matchesPerPlugin, sort.options?.type) + matches.push(matchesPerPlugin) + } } - matches = applyPrefix(matches, context) + // Only keep the result of the very first plugin if we are dealing with + // arbitrary values, to protect against ambiguity. + if (isArbitraryValue(modifier) && matches.length > 1) { + let typesPerPlugin = matches.map((match) => new Set([...(typesByMatches.get(match) ?? [])])) + + // Remove duplicates, so that we can detect proper unique types for each plugin. + for (let pluginTypes of typesPerPlugin) { + for (let type of pluginTypes) { + let removeFromOwnGroup = false + + for (let otherGroup of typesPerPlugin) { + if (pluginTypes === otherGroup) continue + + if (otherGroup.has(type)) { + otherGroup.delete(type) + removeFromOwnGroup = true + } + } + + if (removeFromOwnGroup) pluginTypes.delete(type) + } + } + + let messages = [] + + for (let [idx, group] of typesPerPlugin.entries()) { + for (let type of group) { + let rules = matches[idx] + .map(([, rule]) => rule) + .flat() + .map((rule) => + rule + .toString() + .split('\n') + .slice(1, -1) // Remove selector and closing '}' + .map((line) => line.trim()) + .map((x) => ` ${x}`) // Re-indent + .join('\n') + ) + .join('\n\n') + + messages.push( + ` - Replace "${candidate}" with "${candidate.replace( + '[', + `[${type}:` + )}" for:\n${rules}\n` + ) + break + } + } + + log.warn([ + // TODO: Update URL + `The class "${candidate}" is ambiguous and matches multiple utilities. Use a type hint (https://tailwindcss.com/docs/just-in-time-mode#ambiguous-values) to fix this.`, + '', + ...messages, + `If this is just part of your content and not a class, replace it with "${candidate + .replace('[', '[') + .replace(']', ']')}" to silence this warning.`, + ]) + continue + } + + matches = applyPrefix(matches.flat(), context) if (important) { matches = applyImportant(matches, context) @@ -317,4 +390,8 @@ function generateRules(candidates, context) { }) } +function isArbitraryValue(input) { + return input.startsWith('[') && input.endsWith(']') +} + export { resolveMatches, generateRules } diff --git a/tests/arbitrary-values.test.js b/tests/arbitrary-values.test.js index f264faa0c9e4..cde5846382d6 100644 --- a/tests/arbitrary-values.test.js +++ b/tests/arbitrary-values.test.js @@ -197,3 +197,15 @@ it('should not convert escaped underscores with spaces', () => { `) }) }) + +it('should warn and not generate if arbitrary values are ambigu', () => { + // If we don't protect against this, then `bg-[200px_100px]` would both + // generate the background-size as well as the background-position utilities. + let config = { + content: [{ raw: html`
` }], + } + + return run('@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchFormattedCss(css``) + }) +}) From 2340987a9acf75ed1ae38470e017904e1b2bdbd1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Sep 2021 13:52:24 +0200 Subject: [PATCH 2/7] immediately take the `safelist` values into account Currently we had to manually add them in the `setupTrackingContext`, `setupWatchingContext` and the `cli`. This was a bit cumbersome, because the `safelist` function (to resolve regex patterns) was implemented on the context. This means that we had to do something like this: ```js let changedContent = [] let context = createContext(config, changedContent) for (let content of context.safelist()) { changedContent.push(content) } ``` This just feels wrong in general, so now it is handled internally for you which means that we can't mess it up anymore in those 3 spots. --- src/cli.js | 2 +- src/lib/setupContextUtils.js | 81 ++++++++++++++++----------------- src/lib/setupTrackingContext.js | 2 - src/lib/setupWatchingContext.js | 2 - 4 files changed, 41 insertions(+), 46 deletions(-) diff --git a/src/cli.js b/src/cli.js index 2e49a63a9a97..cd2a27109ef9 100644 --- a/src/cli.js +++ b/src/cli.js @@ -434,7 +434,7 @@ async function build() { } function extractContent(config) { - return config.content.content.concat(config.content.safelist) + return config.content.files } function extractFileGlobs(config) { diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 360197c261e3..9cc59848751a 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -561,16 +561,14 @@ function registerPlugins(plugins, context) { // let warnedAbout = new Set([]) - context.safelist = function () { - let safelist = (context.tailwindConfig.safelist ?? []).filter(Boolean) - if (safelist.length <= 0) return [] - let output = [] + let safelist = (context.tailwindConfig.safelist ?? []).filter(Boolean) + if (safelist.length > 0) { let checks = [] for (let value of safelist) { if (typeof value === 'string') { - output.push(value) + context.changedContent.push({ content: value, extension: 'html' }) continue } @@ -589,51 +587,52 @@ function registerPlugins(plugins, context) { checks.push(value) } - if (checks.length <= 0) return output.map((value) => ({ raw: value, extension: 'html' })) - - let patternMatchingCount = new Map() - - for (let util of classList) { - let utils = Array.isArray(util) - ? (() => { - let [utilName, options] = util - return Object.keys(options?.values ?? {}).map((value) => formatClass(utilName, value)) - })() - : [util] - - for (let util of utils) { - for (let { pattern, variants = [] } of checks) { - // RegExp with the /g flag are stateful, so let's reset the last - // index pointer to reset the state. - pattern.lastIndex = 0 - - if (!patternMatchingCount.has(pattern)) { - patternMatchingCount.set(pattern, 0) - } + if (checks.length > 0) { + let patternMatchingCount = new Map() + + for (let util of classList) { + let utils = Array.isArray(util) + ? (() => { + let [utilName, options] = util + return Object.keys(options?.values ?? {}).map((value) => formatClass(utilName, value)) + })() + : [util] + + for (let util of utils) { + for (let { pattern, variants = [] } of checks) { + // RegExp with the /g flag are stateful, so let's reset the last + // index pointer to reset the state. + pattern.lastIndex = 0 + + if (!patternMatchingCount.has(pattern)) { + patternMatchingCount.set(pattern, 0) + } - if (!pattern.test(util)) continue + if (!pattern.test(util)) continue - patternMatchingCount.set(pattern, patternMatchingCount.get(pattern) + 1) + patternMatchingCount.set(pattern, patternMatchingCount.get(pattern) + 1) - output.push(util) - for (let variant of variants) { - output.push(variant + context.tailwindConfig.separator + util) + context.changedContent.push({ content: util, extension: 'html' }) + for (let variant of variants) { + context.changedContent.push({ + content: variant + context.tailwindConfig.separator + util, + extension: 'html', + }) + } } } } - } - for (let [regex, count] of patternMatchingCount.entries()) { - if (count !== 0) continue + for (let [regex, count] of patternMatchingCount.entries()) { + if (count !== 0) continue - log.warn([ - // TODO: Improve this warning message - `You have a regex pattern in your "safelist" config (${regex}) that doesn't match any utilities.`, - 'For more info, visit https://tailwindcss.com/docs/...', - ]) + log.warn([ + // TODO: Improve this warning message + `You have a regex pattern in your "safelist" config (${regex}) that doesn't match any utilities.`, + 'For more info, visit https://tailwindcss.com/docs/...', + ]) + } } - - return output.map((value) => ({ raw: value, extension: 'html' })) } // Generate a list of strings for autocompletion purposes. Colors will have a diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index e3d7898e187b..4405b615e80f 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -79,8 +79,6 @@ function getTailwindConfig(configOrPath) { function resolvedChangedContent(context, candidateFiles, fileModifiedMap) { let changedContent = context.tailwindConfig.content.content .filter((item) => typeof item.raw === 'string') - .concat(context.tailwindConfig.content.safelist) - .concat(context.safelist()) .map(({ raw, extension }) => ({ content: raw, extension })) for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) { diff --git a/src/lib/setupWatchingContext.js b/src/lib/setupWatchingContext.js index 2ecee6e6bc07..6dc27807e7e0 100644 --- a/src/lib/setupWatchingContext.js +++ b/src/lib/setupWatchingContext.js @@ -187,8 +187,6 @@ function getTailwindConfig(configOrPath) { function resolvedChangedContent(context, candidateFiles) { let changedContent = context.tailwindConfig.content.content .filter((item) => typeof item.raw === 'string') - .concat(context.tailwindConfig.content.safelist) - .concat(context.safelist()) .map(({ raw, extension }) => ({ content: raw, extension })) for (let changedFile of resolveChangedFiles(context, candidateFiles)) { From 28deda0a10540f6d78424cfe1a8eb5973e7dcad9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Sep 2021 13:54:30 +0200 Subject: [PATCH 3/7] drop the dot from the extension Our transformers and extractors are implemented for `html` for example. However the `path.extname()` returns `.html`. This isn't an issue by default, but it could be for with custom extractors / transformers. --- src/cli.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli.js b/src/cli.js index cd2a27109ef9..f4e7f9f7e53d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -464,7 +464,7 @@ async function build() { for (let file of files) { changedContent.push({ content: fs.readFileSync(path.resolve(file), 'utf8'), - extension: path.extname(file), + extension: path.extname(file).slice(1), }) } @@ -726,7 +726,7 @@ async function build() { chain = chain.then(async () => { changedContent.push({ content: fs.readFileSync(path.resolve(file), 'utf8'), - extension: path.extname(file), + extension: path.extname(file).slice(1), }) await rebuild(config) @@ -738,7 +738,7 @@ async function build() { chain = chain.then(async () => { changedContent.push({ content: fs.readFileSync(path.resolve(file), 'utf8'), - extension: path.extname(file), + extension: path.extname(file).slice(1), }) await rebuild(config) From a5e5268fd16d253bf6c0830b9b0d02233fa64231 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Sep 2021 18:26:39 +0200 Subject: [PATCH 4/7] normalize the configuration --- src/lib/expandTailwindAtRules.js | 21 ----- src/lib/setupTrackingContext.js | 4 +- src/lib/setupWatchingContext.js | 4 +- src/util/normalizeConfig.js | 155 +++++++++++++++++++++++++++++++ src/util/resolveConfig.js | 58 +----------- tests/normalize-config.test.js | 48 ++++++++++ 6 files changed, 208 insertions(+), 82 deletions(-) create mode 100644 src/util/normalizeConfig.js create mode 100644 tests/normalize-config.test.js diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js index bf8273223801..36221358e9a0 100644 --- a/src/lib/expandTailwindAtRules.js +++ b/src/lib/expandTailwindAtRules.js @@ -35,21 +35,6 @@ const builtInTransformers = { function getExtractor(tailwindConfig, fileExtension) { let extractors = tailwindConfig.content.extract - let contentOptions = tailwindConfig.content.options - - if (typeof extractors === 'function') { - extractors = { - DEFAULT: extractors, - } - } - if (contentOptions.defaultExtractor) { - extractors.DEFAULT = contentOptions.defaultExtractor - } - for (let { extensions, extractor } of contentOptions.extractors || []) { - for (let extension of extensions) { - extractors[extension] = extractor - } - } return ( extractors[fileExtension] || @@ -62,12 +47,6 @@ function getExtractor(tailwindConfig, fileExtension) { function getTransformer(tailwindConfig, fileExtension) { let transformers = tailwindConfig.content.transform - if (typeof transformers === 'function') { - transformers = { - DEFAULT: transformers, - } - } - return ( transformers[fileExtension] || transformers.DEFAULT || diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index 4405b615e80f..32d933e0da57 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -26,7 +26,7 @@ function getCandidateFiles(context, tailwindConfig) { return candidateFilesCache.get(context) } - let candidateFiles = tailwindConfig.content.content + let candidateFiles = tailwindConfig.content.files .filter((item) => typeof item === 'string') .map((contentPath) => normalizePath(contentPath)) @@ -77,7 +77,7 @@ function getTailwindConfig(configOrPath) { } function resolvedChangedContent(context, candidateFiles, fileModifiedMap) { - let changedContent = context.tailwindConfig.content.content + let changedContent = context.tailwindConfig.content.files .filter((item) => typeof item.raw === 'string') .map(({ raw, extension }) => ({ content: raw, extension })) diff --git a/src/lib/setupWatchingContext.js b/src/lib/setupWatchingContext.js index 6dc27807e7e0..7f88c0aeeb29 100644 --- a/src/lib/setupWatchingContext.js +++ b/src/lib/setupWatchingContext.js @@ -147,7 +147,7 @@ function getCandidateFiles(context, tailwindConfig) { return candidateFilesCache.get(context) } - let candidateFiles = tailwindConfig.content.content + let candidateFiles = tailwindConfig.content.files .filter((item) => typeof item === 'string') .map((contentPath) => normalizePath(contentPath)) @@ -185,7 +185,7 @@ function getTailwindConfig(configOrPath) { } function resolvedChangedContent(context, candidateFiles) { - let changedContent = context.tailwindConfig.content.content + let changedContent = context.tailwindConfig.content.files .filter((item) => typeof item.raw === 'string') .map(({ raw, extension }) => ({ content: raw, extension })) diff --git a/src/util/normalizeConfig.js b/src/util/normalizeConfig.js new file mode 100644 index 000000000000..fae262ead074 --- /dev/null +++ b/src/util/normalizeConfig.js @@ -0,0 +1,155 @@ +import log from './log' + +let warnedAbout = new Set() + +function validate(config, options) { + if (!config) return false + + for (let [k, v] of Object.entries(config)) { + let types = options[k] + + if (!types) return false + + // Property SHOULD exist, this catches unused keys like `options` + if (!types.includes(undefined) && !options.hasOwnProperty(k)) { + return false + } + + if ( + !types.some((type) => { + if (type === undefined) return true + return v instanceof type + }) + ) { + return false + } + } + + for (let [k, types] of Object.entries(options)) { + let value = config[k] + if ( + !types.some((type) => { + if (type === undefined) return true + return value instanceof type + }) + ) { + return false + } + } + + return true +} + +export function normalizeConfig(config) { + // Quick structure validation + let valid = validate(config.content, { + files: [Array], + extract: [undefined, Function, Object], + }) + + if (!valid) { + if (!warnedAbout.has('purge-deprecation')) { + // TODO: Improve this warning message + log.warn([ + 'The `purge` option in your tailwind.config.js file has been deprecated.', + 'Please look at the docs.', + ]) + warnedAbout.add('purge-deprecation') + } + } + + // Normalize the `safelist` + config.safelist = (() => { + let { content, purge, safelist } = config + + if (Array.isArray(safelist)) return safelist + if (Array.isArray(content?.safelist)) return content.safelist + if (Array.isArray(purge?.safelist)) return purge.safelist + if (Array.isArray(purge?.options?.safelist)) return purge.options.safelist + + return [] + })() + + // Normalize the `content` + config.content = { + files: (() => { + let { content, purge } = config + + if (Array.isArray(purge)) return purge + if (Array.isArray(purge?.content)) return purge.content + if (Array.isArray(content)) return content + if (Array.isArray(content?.content)) return content.content + if (Array.isArray(content?.files)) return content.files + + return [] + })(), + + extract: (() => { + let extract = (() => { + if (config.purge?.extract) return config.purge.extract + if (config.content?.extract) return config.content.extract + + if (config.purge?.extract?.DEFAULT) return config.purge.extract.DEFAULT + if (config.content?.extract?.DEFAULT) return config.content.extract.DEFAULT + + if (config.purge?.options?.defaultExtractor) return config.purge.options.defaultExtractor + if (config.content?.options?.defaultExtractor) + return config.content.options.defaultExtractor + + if (config.purge?.options?.extractors) return config.purge.options.extractors + if (config.content?.options?.extractors) return config.content.options.extractors + + return {} + })() + + let extractors = {} + + // Functions + if (typeof extract === 'function') { + extractors.DEFAULT = extract + } + + // Arrays + else if (Array.isArray(extract)) { + for (let { extensions, extractor } of extract ?? []) { + for (let extension of extensions) { + extractors[extension] = extractor + } + } + } + + // Objects + else if (typeof extract === 'object' && extract !== null) { + Object.assign(extractors, extract) + } + + return extractors + })(), + + transform: (() => { + let transform = (() => { + if (config.purge?.transform) return config.purge.transform + if (config.content?.transform) return config.content.transform + + if (config.purge?.transform?.DEFAULT) return config.purge.transform.DEFAULT + if (config.content?.transform?.DEFAULT) return config.content.transform.DEFAULT + + return {} + })() + + let transformers = {} + + if (typeof transform === 'function') { + transformers.DEFAULT = transform + } + + if (typeof transform === 'object' && transform !== null) { + Object.assign(transformers, transform) + } + + return transformers + })(), + } + + return config +} diff --git a/src/util/resolveConfig.js b/src/util/resolveConfig.js index d5e2c0abcc7c..e631a973f521 100644 --- a/src/util/resolveConfig.js +++ b/src/util/resolveConfig.js @@ -3,9 +3,9 @@ import corePluginList from '../corePluginList' import configurePlugins from './configurePlugins' import defaultConfig from '../../stubs/defaultConfig.stub' import colors from '../public/colors' -import log from './log' import { defaults } from './defaults' import { toPath } from './toPath' +import { normalizeConfig } from './normalizeConfig' function isFunction(input) { return typeof input === 'function' @@ -221,59 +221,3 @@ export default function resolveConfig(configs) { ) ) } - -let warnedAbout = new Set() -function normalizeConfig(config) { - if (!warnedAbout.has('purge-deprecation') && config.hasOwnProperty('purge')) { - log.warn([ - 'The `purge` option in your tailwind.config.js file has been deprecated.', - 'Please rename this to `content` instead.', - ]) - warnedAbout.add('purge-deprecation') - } - - config.content = { - content: (() => { - let { content, purge } = config - - if (Array.isArray(purge)) return purge - if (Array.isArray(purge?.content)) return purge.content - if (Array.isArray(content)) return content - if (Array.isArray(content?.content)) return content.content - - return [] - })(), - safelist: (() => { - let { content, purge } = config - - let [safelistKey, safelistPaths] = (() => { - if (Array.isArray(content?.safelist)) return ['content.safelist', content.safelist] - if (Array.isArray(purge?.safelist)) return ['purge.safelist', purge.safelist] - if (Array.isArray(purge?.options?.safelist)) - return ['purge.options.safelist', purge.options.safelist] - return [null, []] - })() - - return safelistPaths.map((content) => { - if (typeof content === 'string') { - return { raw: content, extension: 'html' } - } - - if (content instanceof RegExp) { - throw new Error( - `Values inside '${safelistKey}' can only be of type 'string', found 'regex'.` - ) - } - - throw new Error( - `Values inside '${safelistKey}' can only be of type 'string', found '${typeof content}'.` - ) - }) - })(), - extract: config.content?.extract || config.purge?.extract || {}, - options: config.content?.options || config.purge?.options || {}, - transform: config.content?.transform || config.purge?.transform || {}, - } - - return config -} diff --git a/tests/normalize-config.test.js b/tests/normalize-config.test.js new file mode 100644 index 000000000000..4ead50794476 --- /dev/null +++ b/tests/normalize-config.test.js @@ -0,0 +1,48 @@ +import { run, css } from './util/run' + +it.each` + config + ${{ purge: [{ raw: 'text-center' }] }} + ${{ purge: { content: [{ raw: 'text-center' }] } }} + ${{ content: { content: [{ raw: 'text-center' }] } }} +`('should normalize content $config', ({ config }) => { + return run('@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .text-center { + text-align: center; + } + `) + }) +}) + +it.each` + config + ${{ purge: { safelist: ['text-center'] } }} + ${{ purge: { options: { safelist: ['text-center'] } } }} + ${{ content: { safelist: ['text-center'] } }} +`('should normalize safelist $config', ({ config }) => { + return run('@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .text-center { + text-align: center; + } + `) + }) +}) + +it.each` + config + ${{ content: [{ raw: 'text-center' }], purge: { extract: () => ['font-bold'] } }} + ${{ content: [{ raw: 'text-center' }], purge: { extract: { DEFAULT: () => ['font-bold'] } } }} + ${{ content: [{ raw: 'text-center' }], purge: { options: { defaultExtractor: () => ['font-bold'] } } }} + ${{ content: [{ raw: 'text-center' }], purge: { options: { extractors: [{ extractor: () => ['font-bold'], extensions: ['html'] }] } } }} + ${{ content: [{ raw: 'text-center' }], purge: { extract: { html: () => ['font-bold'] } } }} +`('should normalize extractors $config', ({ config }) => { + return run('@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .font-bold { + font-weight: 700; + } + `) + }) +}) From 9af1eaa645b2834edcb19e31fea6f479004eccbf Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Sep 2021 18:27:04 +0200 Subject: [PATCH 5/7] make shared cache local per extractor --- src/lib/expandTailwindAtRules.js | 18 ++++++++++++------ src/lib/sharedState.js | 3 --- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js index 36221358e9a0..d090b832f1a9 100644 --- a/src/lib/expandTailwindAtRules.js +++ b/src/lib/expandTailwindAtRules.js @@ -1,10 +1,10 @@ +import LRU from 'quick-lru' import * as sharedState from './sharedState' import { generateRules } from './generateRules' import bigSign from '../util/bigSign' import cloneNodes from '../util/cloneNodes' let env = sharedState.env -let contentMatchCache = sharedState.contentMatchCache const PATTERNS = [ /([^<>"'`\s]*\[\w*'[^"`\s]*'?\])/.source, // font-['some_font',sans-serif] @@ -55,10 +55,16 @@ function getTransformer(tailwindConfig, fileExtension) { ) } +let extractorCache = new WeakMap() + // Scans template contents for possible classes. This is a hot path on initial build but // not too important for subsequent builds. The faster the better though — if we can speed // up these regexes by 50% that could cut initial build time by like 20%. -function getClassCandidates(content, extractor, contentMatchCache, candidates, seen) { +function getClassCandidates(content, extractor, candidates, seen) { + if (!extractorCache.has(extractor)) { + extractorCache.set(extractor, new LRU({ maxSize: 25000 })) + } + for (let line of content.split('\n')) { line = line.trim() @@ -67,8 +73,8 @@ function getClassCandidates(content, extractor, contentMatchCache, candidates, s } seen.add(line) - if (contentMatchCache.has(line)) { - for (let match of contentMatchCache.get(line)) { + if (extractorCache.get(extractor).has(line)) { + for (let match of extractorCache.get(extractor).get(line)) { candidates.add(match) } } else { @@ -79,7 +85,7 @@ function getClassCandidates(content, extractor, contentMatchCache, candidates, s candidates.add(match) } - contentMatchCache.set(line, lineMatchesSet) + extractorCache.get(extractor).set(line, lineMatchesSet) } } } @@ -168,7 +174,7 @@ export default function expandTailwindAtRules(context) { for (let { content, extension } of context.changedContent) { let transformer = getTransformer(context.tailwindConfig, extension) let extractor = getExtractor(context.tailwindConfig, extension) - getClassCandidates(transformer(content), extractor, contentMatchCache, candidates, seen) + getClassCandidates(transformer(content), extractor, candidates, seen) } // --- diff --git a/src/lib/sharedState.js b/src/lib/sharedState.js index 78ed1ce5d9ff..a15e8cc3d122 100644 --- a/src/lib/sharedState.js +++ b/src/lib/sharedState.js @@ -1,5 +1,3 @@ -import LRU from 'quick-lru' - export const env = { TAILWIND_MODE: process.env.TAILWIND_MODE, NODE_ENV: process.env.NODE_ENV, @@ -10,4 +8,3 @@ export const env = { export const contextMap = new Map() export const configContextMap = new Map() export const contextSourcesMap = new Map() -export const contentMatchCache = new LRU({ maxSize: 25000 }) From c464c3fac0ac09f7edaef75eb367f66e58d81737 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Sep 2021 18:27:18 +0200 Subject: [PATCH 6/7] ensure we always have an `extension` Defaults to `html` --- src/lib/setupTrackingContext.js | 2 +- src/lib/setupWatchingContext.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/setupTrackingContext.js b/src/lib/setupTrackingContext.js index 32d933e0da57..d4015c5bf59e 100644 --- a/src/lib/setupTrackingContext.js +++ b/src/lib/setupTrackingContext.js @@ -79,7 +79,7 @@ function getTailwindConfig(configOrPath) { function resolvedChangedContent(context, candidateFiles, fileModifiedMap) { let changedContent = context.tailwindConfig.content.files .filter((item) => typeof item.raw === 'string') - .map(({ raw, extension }) => ({ content: raw, extension })) + .map(({ raw, extension = 'html' }) => ({ content: raw, extension })) for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) { let content = fs.readFileSync(changedFile, 'utf8') diff --git a/src/lib/setupWatchingContext.js b/src/lib/setupWatchingContext.js index 7f88c0aeeb29..c20d043dd134 100644 --- a/src/lib/setupWatchingContext.js +++ b/src/lib/setupWatchingContext.js @@ -187,7 +187,7 @@ function getTailwindConfig(configOrPath) { function resolvedChangedContent(context, candidateFiles) { let changedContent = context.tailwindConfig.content.files .filter((item) => typeof item.raw === 'string') - .map(({ raw, extension }) => ({ content: raw, extension })) + .map(({ raw, extension = 'html' }) => ({ content: raw, extension })) for (let changedFile of resolveChangedFiles(context, candidateFiles)) { let content = fs.readFileSync(changedFile, 'utf8') From 093db284e8ffb3d506edc0e283345bcf938ed0ed Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Sep 2021 18:27:34 +0200 Subject: [PATCH 7/7] splitup custom-extractors test --- tests/custom-extractors.test.js | 118 +++++++++++++++++--------------- 1 file changed, 61 insertions(+), 57 deletions(-) diff --git a/tests/custom-extractors.test.js b/tests/custom-extractors.test.js index 042ff27874e8..9ce08dcb254f 100644 --- a/tests/custom-extractors.test.js +++ b/tests/custom-extractors.test.js @@ -11,80 +11,84 @@ function customExtractor(content) { let expectedPath = path.resolve(__dirname, './custom-extractors.test.css') let expected = fs.readFileSync(expectedPath, 'utf8') -test('defaultExtractor', () => { - let config = { - content: { - content: [path.resolve(__dirname, './custom-extractors.test.html')], - options: { - defaultExtractor: customExtractor, +describe('modern', () => { + test('extract.DEFAULT', () => { + let config = { + content: { + files: [path.resolve(__dirname, './custom-extractors.test.html')], + extract: { + DEFAULT: customExtractor, + }, }, - }, - } + } - return run('@tailwind utilities', config).then((result) => { - expect(result.css).toMatchFormattedCss(expected) + return run('@tailwind utilities', config).then((result) => { + expect(result.css).toMatchFormattedCss(expected) + }) }) -}) -test('extractors array', () => { - let config = { - content: { - content: [path.resolve(__dirname, './custom-extractors.test.html')], - options: { - extractors: [ - { - extractor: customExtractor, - extensions: ['html'], - }, - ], + test('extract.{extension}', () => { + let config = { + content: { + files: [path.resolve(__dirname, './custom-extractors.test.html')], + extract: { + html: customExtractor, + }, }, - }, - } + } - return run('@tailwind utilities', config).then((result) => { - expect(result.css).toMatchFormattedCss(expected) + return run('@tailwind utilities', config).then((result) => { + expect(result.css).toMatchFormattedCss(expected) + }) }) }) -test('extract function', () => { - let config = { - content: { - content: [path.resolve(__dirname, './custom-extractors.test.html')], - extract: customExtractor, - }, - } +describe('legacy', () => { + test('defaultExtractor', () => { + let config = { + content: { + content: [path.resolve(__dirname, './custom-extractors.test.html')], + options: { + defaultExtractor: customExtractor, + }, + }, + } - return run('@tailwind utilities', config).then((result) => { - expect(result.css).toMatchFormattedCss(expected) + return run('@tailwind utilities', config).then((result) => { + expect(result.css).toMatchFormattedCss(expected) + }) }) -}) -test('extract.DEFAULT', () => { - let config = { - content: { - content: [path.resolve(__dirname, './custom-extractors.test.html')], - extract: { - DEFAULT: customExtractor, + test('extractors array', () => { + let config = { + content: { + content: [path.resolve(__dirname, './custom-extractors.test.html')], + options: { + extractors: [ + { + extractor: customExtractor, + extensions: ['html'], + }, + ], + }, }, - }, - } + } - return run('@tailwind utilities', config).then((result) => { - expect(result.css).toMatchFormattedCss(expected) + return run('@tailwind utilities', config).then((result) => { + expect(result.css).toMatchFormattedCss(expected) + }) }) -}) -test('extract.{extension}', () => { - let config = { - content: { - content: [path.resolve(__dirname, './custom-extractors.test.html')], - extract: { - html: customExtractor, + test('extract function', () => { + let config = { + content: { + content: [path.resolve(__dirname, './custom-extractors.test.html')], + extract: customExtractor, }, - }, - } + } - return run('@tailwind utilities', config).then((result) => { - expect(result.css).toMatchFormattedCss(expected) + return run('@tailwind utilities', config).then((result) => { + expect(result.css).toMatchFormattedCss(expected) + }) }) })