diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a980b7f301..261693348977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support PostCSS config options in config file in CLI ([#8226](https://github.com/tailwindlabs/tailwindcss/pull/8226)) - Remove default `[hidden]` style in preflight ([#8248](https://github.com/tailwindlabs/tailwindcss/pull/8248)) - Only check selectors containing base apply candidates for circular dependencies ([#8222](https://github.com/tailwindlabs/tailwindcss/pull/8222)) -- Handle utilities with multiple and/or grouped selectors better ([#8262](https://github.com/tailwindlabs/tailwindcss/pull/8262)) +- Rewrite default class extractor ([#8204](https://github.com/tailwindlabs/tailwindcss/pull/8204)) ### Added diff --git a/src/lib/defaultExtractor.js b/src/lib/defaultExtractor.js index e211c598fea7..523d9bab3dad 100644 --- a/src/lib/defaultExtractor.js +++ b/src/lib/defaultExtractor.js @@ -1,42 +1,160 @@ -const PATTERNS = [ - /(?:\['([^'\s]+[^<>"'`\s:\\])')/.source, // ['text-lg' -> text-lg - /(?:\["([^"\s]+[^<>"'`\s:\\])")/.source, // ["text-lg" -> text-lg - /(?:\[`([^`\s]+[^<>"'`\s:\\])`)/.source, // [`text-lg` -> text-lg - /([^${(<>"'`\s]*\[\w*'[^"`\s]*'?\])/.source, // font-['some_font',sans-serif] - /([^${(<>"'`\s]*\[\w*"[^'`\s]*"?\])/.source, // font-["some_font",sans-serif] - /([^<>"'`\s]*\[\w*\('[^"'`\s]*'\)\])/.source, // bg-[url('...')] - /([^<>"'`\s]*\[\w*\("[^"'`\s]*"\)\])/.source, // bg-[url("...")] - /([^<>"'`\s]*\[\w*\('[^"`\s]*'\)\])/.source, // bg-[url('...'),url('...')] - /([^<>"'`\s]*\[\w*\("[^'`\s]*"\)\])/.source, // bg-[url("..."),url("...")] - /([^<>"'`\s]*\[[^<>"'`\s]*\('[^"`\s]*'\)+\])/.source, // h-[calc(100%-theme('spacing.1'))] - /([^<>"'`\s]*\[[^<>"'`\s]*\("[^'`\s]*"\)+\])/.source, // h-[calc(100%-theme("spacing.1"))] - /([^${(<>"'`\s]*\['[^"'`\s]*'\])/.source, // `content-['hello']` but not `content-['hello']']` - /([^${(<>"'`\s]*\["[^"'`\s]*"\])/.source, // `content-["hello"]` but not `content-["hello"]"]` - /([^<>"'`\s]*\[[^<>"'`\s]*:[^\]\s]*\])/.source, // `[attr:value]` - /([^<>"'`\s]*\[[^<>"'`\s]*:'[^"'`\s]*'\])/.source, // `[content:'hello']` but not `[content:"hello"]` - /([^<>"'`\s]*\[[^<>"'`\s]*:"[^"'`\s]*"\])/.source, // `[content:"hello"]` but not `[content:'hello']` - /([^<>"'`\s]*\[[^"'`\s]+\][^<>"'`\s]*)/.source, // `fill-[#bada55]`, `fill-[#bada55]/50` - /([^"'`\s]*[^<>"'`\s:\\])/.source, // `:font-bold` - /([^<>"'`\s]*[^"'`\s:\\])/.source, // `px-1.5`, `uppercase` but not `uppercase:` - - // Arbitrary properties - // /([^"\s]*\[[^\s]+?\][^"\s]*)/.source, - // /([^'\s]*\[[^\s]+?\][^'\s]*)/.source, - // /([^`\s]*\[[^\s]+?\][^`\s]*)/.source, -].join('|') - -const BROAD_MATCH_GLOBAL_REGEXP = new RegExp(PATTERNS, 'g') -const INNER_MATCH_GLOBAL_REGEXP = /[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g +import * as regex from './regex' + +let patterns = Array.from(buildRegExps()) /** * @param {string} content */ export function defaultExtractor(content) { - let broadMatches = content.matchAll(BROAD_MATCH_GLOBAL_REGEXP) - let innerMatches = content.match(INNER_MATCH_GLOBAL_REGEXP) || [] - let results = [...broadMatches, ...innerMatches].flat().filter((v) => v !== undefined) + /** @type {(string|string)[]} */ + let results = [] + + for (let pattern of patterns) { + results.push(...(content.match(pattern) ?? [])) + } + + return results.filter((v) => v !== undefined).map(clipAtBalancedParens) +} + +function* buildRegExps() { + yield regex.pattern([ + // Variants + /((?=([^\s"'\\\[]+:))\2)?/, + + // Important (optional) + /!?/, + + regex.any([ + // Arbitrary properties + /\[[^\s:'"]+:[^\s\]]+\]/, + + // Utilities + regex.pattern([ + // Utility Name / Group Name + /-?(?:\w+)/, + + // Normal/Arbitrary values + regex.optional( + regex.any([ + regex.pattern([ + // Arbitrary values + /-\[[^\s:]+\]/, + + // Not immediately followed by an `{[(` + /(?![{([]])/, + + // optionally followed by an opacity modifier + /(?:\/[^\s'"\\$]*)?/, + ]), + + regex.pattern([ + // Arbitrary values + /-\[[^\s]+\]/, + + // Not immediately followed by an `{[(` + /(?![{([]])/, + + // optionally followed by an opacity modifier + /(?:\/[^\s'"\\$]*)?/, + ]), + + // Normal values w/o quotes — may include an opacity modifier + /[-\/][^\s'"\\$={]*/, + ]) + ), + ]), + ]), + ]) + + // 5. Inner matches + // yield /[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g +} + +// We want to capture any "special" characters +// AND the characters immediately following them (if there is one) +let SPECIALS = /([\[\]'"`])([^\[\]'"`])?/g +let ALLOWED_CLASS_CHARACTERS = /[^"'`\s<>\]]+/ + +/** + * Clips a string ensuring that parentheses, quotes, etc… are balanced + * Used for arbitrary values only + * + * We will go past the end of the balanced parens until we find a non-class character + * + * Depth matching behavior: + * w-[calc(100%-theme('spacing[some_key][1.5]'))]'] + * ┬ ┬ ┬┬ ┬ ┬┬ ┬┬┬┬┬┬┬ + * 1 2 3 4 34 3 210 END + * ╰────┴──────────┴────────┴────────┴┴───┴─┴┴┴ + * + * @param {string} input + */ +function clipAtBalancedParens(input) { + // We are care about this for arbitrary values + if (!input.includes('-[')) { + return input + } + + let depth = 0 + let openStringTypes = [] + + // Find all parens, brackets, quotes, etc + // Stop when we end at a balanced pair + // This is naive and will treat mismatched parens as balanced + // This shouldn't be a problem in practice though + let matches = input.matchAll(SPECIALS) + + // We can't use lookbehind assertions because we have to support Safari + // So, instead, we've emulated it using capture groups and we'll re-work the matches to accommodate + matches = Array.from(matches).flatMap((match) => { + const [, ...groups] = match + + return groups.map((group, idx) => + Object.assign([], match, { + index: match.index + idx, + 0: group, + }) + ) + }) + + for (let match of matches) { + let char = match[0] + let inStringType = openStringTypes[openStringTypes.length - 1] + + if (char === inStringType) { + openStringTypes.pop() + } else if (char === "'" || char === '"' || char === '`') { + openStringTypes.push(char) + } + + if (inStringType) { + continue + } else if (char === '[') { + depth++ + continue + } else if (char === ']') { + depth-- + continue + } + + // We've gone one character past the point where we should stop + // This means that there was an extra closing `]` + // We'll clip to just before it + if (depth < 0) { + return input.substring(0, match.index) + } + + // We've finished balancing the brackets but there still may be characters that can be included + // For example in the class `text-[#336699]/[.35]` + // The depth goes to `0` at the closing `]` but goes up again at the `[` + + // If we're at zero and encounter a non-class character then we clip the class there + if (depth === 0 && !ALLOWED_CLASS_CHARACTERS.test(char)) { + return input.substring(0, match.index) + } + } - return results + return input } // Regular utilities diff --git a/src/lib/expandApplyAtRules.js b/src/lib/expandApplyAtRules.js index 0210684c8f00..1bef657fc6cf 100644 --- a/src/lib/expandApplyAtRules.js +++ b/src/lib/expandApplyAtRules.js @@ -34,6 +34,15 @@ function extractClasses(node) { return Object.assign(classes, { groups: normalizedGroups }) } +let selectorExtractor = parser((root) => root.nodes.map((node) => node.toString())) + +/** + * @param {string} ruleSelectors + */ +function extractSelectors(ruleSelectors) { + return selectorExtractor.transformSync(ruleSelectors) +} + function extractBaseCandidates(candidates, separator) { let baseClasses = new Set() @@ -295,10 +304,9 @@ function processApply(root, context, localCache) { function replaceSelector(selector, utilitySelectors, candidate) { let needle = `.${escapeClassName(candidate)}` let needles = [...new Set([needle, needle.replace(/\\2c /g, '\\,')])] - let utilitySelectorsList = utilitySelectors.split(/\s*(? { let replaced = [] diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js index b04cf90de6f0..64ba07131e3e 100644 --- a/src/lib/expandTailwindAtRules.js +++ b/src/lib/expandTailwindAtRules.js @@ -17,8 +17,8 @@ const builtInTransformers = { svelte: (content) => content.replace(/(?:^|\s)class:/g, ' '), } -function getExtractor(tailwindConfig, fileExtension) { - let extractors = tailwindConfig.content.extract +function getExtractor(context, fileExtension) { + let extractors = context.tailwindConfig.content.extract return ( extractors[fileExtension] || @@ -165,7 +165,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) + let extractor = getExtractor(context, extension) getClassCandidates(transformer(content), extractor, candidates, seen) } diff --git a/src/lib/regex.js b/src/lib/regex.js new file mode 100644 index 000000000000..5db7657be7cd --- /dev/null +++ b/src/lib/regex.js @@ -0,0 +1,74 @@ +const REGEX_SPECIAL = /[\\^$.*+?()[\]{}|]/g +const REGEX_HAS_SPECIAL = RegExp(REGEX_SPECIAL.source) + +/** + * @param {string|RegExp|Array} source + */ +function toSource(source) { + source = Array.isArray(source) ? source : [source] + + source = source.map((item) => (item instanceof RegExp ? item.source : item)) + + return source.join('') +} + +/** + * @param {string|RegExp|Array} source + */ +export function pattern(source) { + return new RegExp(toSource(source), 'g') +} + +/** + * @param {string|RegExp|Array} source + */ +export function withoutCapturing(source) { + return new RegExp(`(?:${toSource(source)})`, 'g') +} + +/** + * @param {Array} sources + */ +export function any(sources) { + return `(?:${sources.map(toSource).join('|')})` +} + +/** + * @param {string|RegExp} source + */ +export function optional(source) { + return `(?:${toSource(source)})?` +} + +/** + * @param {string|RegExp|Array} source + */ +export function zeroOrMore(source) { + return `(?:${toSource(source)})*` +} + +/** + * Generate a RegExp that matches balanced brackets for a given depth + * We have to specify a depth because JS doesn't support recursive groups using ?R + * + * Based on https://stackoverflow.com/questions/17759004/how-to-match-string-within-parentheses-nested-in-java/17759264#17759264 + * + * @param {string|RegExp|Array} source + */ +export function nestedBrackets(open, close, depth = 1) { + return withoutCapturing([ + escape(open), + /[^\s]*/, + depth === 1 + ? `[^${escape(open)}${escape(close)}\s]*` + : any([`[^${escape(open)}${escape(close)}\s]*`, nestedBrackets(open, close, depth - 1)]), + /[^\s]*/, + escape(close), + ]) +} + +export function escape(string) { + return string && REGEX_HAS_SPECIAL.test(string) + ? string.replace(REGEX_SPECIAL, '\\$&') + : string || '' +} diff --git a/tests/arbitrary-values.test.css b/tests/arbitrary-values.test.css index c7a88df35b34..da43300ad6a9 100644 --- a/tests/arbitrary-values.test.css +++ b/tests/arbitrary-values.test.css @@ -316,6 +316,9 @@ .cursor-\[url\(hand\.cur\)_2_2\2c pointer\] { cursor: url(hand.cur) 2 2, pointer; } +.cursor-\[url\(\'\.\/path_to_hand\.cur\'\)_2_2\2c pointer\] { + cursor: url("./path_to_hand.cur") 2 2, pointer; +} .cursor-\[var\(--value\)\] { cursor: var(--value); } diff --git a/tests/basic-usage.test.js b/tests/basic-usage.test.js index a574ffc60d0c..bad29b66f9b7 100644 --- a/tests/basic-usage.test.js +++ b/tests/basic-usage.test.js @@ -401,3 +401,32 @@ it('should generate styles using :not(.unknown-class) even if `.unknown-class` d `) }) }) + +it('supports multiple backgrounds as arbitrary values even if only some are quoted', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .bg-\[url\(\'\/images\/one-two-three\.png\'\)\2c + linear-gradient\(to_right\2c + _\#eeeeee\2c + _\#000000\)\] { + background-image: url('/images/one-two-three.png'), + linear-gradient(to right, #eeeeee, #000000); + } + `) + }) +}) diff --git a/tests/default-extractor.test.js b/tests/default-extractor.test.js index e1bf6b77c3ad..b6e0b6789d4a 100644 --- a/tests/default-extractor.test.js +++ b/tests/default-extractor.test.js @@ -56,7 +56,11 @@ const htmlExamples = html` let classes11 = ['hover:'] let classes12 = ['hover:\'abc'] let classes13 = ["lg:text-[4px]"] - let classes14 = ["
"] + let classes14 = ["
"] + let classes15 = ["
"] // unknown so dont generate + let classes16 = ["font-[arbitrary,'arbitrary_with_space']"] + let classes17 = ["font-['arbitrary_with_space','arbitrary_2']"] + let classes18 = ["bg-[url('/images/one-two-three.png'),linear-gradient(to_right,_#eeeeee,_#000000)]"] let obj = { lowercase: true, @@ -73,6 +77,10 @@ const htmlExamples = html` "h-[109px]": true } + ` const includes = [ @@ -133,11 +141,16 @@ const includes = [ `lg:text-[4px]`, `lg:text-[24px]`, `content-['>']`, - `hover:test`, + `hover:underline`, `overflow-scroll`, `[--y:theme(colors.blue.500)]`, `w-[calc(100%-theme('spacing.1'))]`, `w-[calc(100%-theme("spacing.2"))]`, + `border-[color:var(--color,theme(colors.cyan.500))]`, + `translate-x-[var(--scroll-offset)]`, + `font-[arbitrary,'arbitrary_with_space']`, + `font-['arbitrary_with_space','arbitrary_2']`, + `bg-[url('/images/one-two-three.png'),linear-gradient(to_right,_#eeeeee,_#000000)]`, ] const excludes = [ @@ -145,6 +158,7 @@ const excludes = [ 'hover:', "hover:'abc", `font-bold`, + `
`, `
`, `test`, ] @@ -193,7 +207,7 @@ test('basic utility classes', async () => { expect(extractions).toContain('pointer-events-none') }) -test('modifiers with basic utilites', async () => { +test('modifiers with basic utilities', async () => { const extractions = defaultExtractor(`
`) @@ -394,26 +408,26 @@ test('with single quotes array within template literal', async () => { const extractions = defaultExtractor(`
`) expect(extractions).toContain('pr-1.5') - expect(extractions).toContain('pr-1') + expect(extractions).not.toContain('pr-1') }) test('with double quotes array within template literal', async () => { const extractions = defaultExtractor(`
`) expect(extractions).toContain('pr-1.5') - expect(extractions).toContain('pr-1') + expect(extractions).not.toContain('pr-1') }) test('with single quotes array within function', async () => { const extractions = defaultExtractor(`document.body.classList.add(['pl-1.5'].join(" "));`) expect(extractions).toContain('pl-1.5') - expect(extractions).toContain('pl-1') + expect(extractions).not.toContain('pl-1') }) test('with double quotes array within function', async () => { const extractions = defaultExtractor(`document.body.classList.add(["pl-1.5"].join(" "));`) expect(extractions).toContain('pl-1.5') - expect(extractions).toContain('pl-1') + expect(extractions).not.toContain('pl-1') })