diff --git a/CHANGELOG.md b/CHANGELOG.md index 096dc2593773..45f8640faa20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Nothing yet! +## [3.0.7] - 2021-12-17 + +### Fixed + +- Don't mutate custom color palette when overriding per-plugin colors ([#6546](https://github.com/tailwindlabs/tailwindcss/pull/6546)) +- Improve circular dependency detection when using `@apply` ([#6588](https://github.com/tailwindlabs/tailwindcss/pull/6588)) +- Only generate variants for non-`user` layers ([#6589](https://github.com/tailwindlabs/tailwindcss/pull/6589)) +- Properly extract classes with arbitrary values in arrays and classes followed by escaped quotes ([#6590](https://github.com/tailwindlabs/tailwindcss/pull/6590)) +- Improve jsx interpolation candidate matching ([#6593](https://github.com/tailwindlabs/tailwindcss/pull/6593)) +- Ensure `@apply` of a rule inside an AtRule works ([#6594](https://github.com/tailwindlabs/tailwindcss/pull/6594)) + ## [3.0.6] - 2021-12-16 ### Fixed @@ -1730,8 +1741,9 @@ No release notes - Everything! -[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v3.0.6...HEAD -[3.0.6]: https://github.com/tailwindlabs/tailwindcss/compare/v3.0.6...v3.0.6 +[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v3.0.7...HEAD +[3.0.7]: https://github.com/tailwindlabs/tailwindcss/compare/v3.0.6...v3.0.7 +[3.0.6]: https://github.com/tailwindlabs/tailwindcss/compare/v3.0.5...v3.0.6 [3.0.5]: https://github.com/tailwindlabs/tailwindcss/compare/v3.0.4...v3.0.5 [3.0.4]: https://github.com/tailwindlabs/tailwindcss/compare/v3.0.3...v3.0.4 [3.0.3]: https://github.com/tailwindlabs/tailwindcss/compare/v3.0.2...v3.0.3 diff --git a/package-lock.json b/package-lock.json index abe4ea6f9b2c..779de0c04768 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tailwindcss", - "version": "3.0.6", + "version": "3.0.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "tailwindcss", - "version": "3.0.6", + "version": "3.0.7", "license": "MIT", "dependencies": { "arg": "^5.0.1", diff --git a/package.json b/package.json index c72ef7b4ffe4..352ef6e98624 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tailwindcss", - "version": "3.0.6", + "version": "3.0.7", "description": "A utility-first CSS framework for rapidly building custom user interfaces.", "license": "MIT", "main": "lib/index.js", diff --git a/src/lib/defaultExtractor.js b/src/lib/defaultExtractor.js new file mode 100644 index 000000000000..eccf8efc42af --- /dev/null +++ b/src/lib/defaultExtractor.js @@ -0,0 +1,31 @@ +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]*'\])/.source, // `content-['hello']` but not `content-['hello']']` + /([^<>"'`\s]*\["[^"'`\s]*"\])/.source, // `content-["hello"]` but not `content-["hello"]"]` + /([^<>"'`\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, // `px-1.5`, `uppercase` but not `uppercase:` +].join('|') + +const BROAD_MATCH_GLOBAL_REGEXP = new RegExp(PATTERNS, 'g') +const INNER_MATCH_GLOBAL_REGEXP = /[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g + +/** + * @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) + + return results +} diff --git a/src/lib/expandApplyAtRules.js b/src/lib/expandApplyAtRules.js index 561343266945..4eff0f693e40 100644 --- a/src/lib/expandApplyAtRules.js +++ b/src/lib/expandApplyAtRules.js @@ -1,22 +1,33 @@ import postcss from 'postcss' import parser from 'postcss-selector-parser' + import { resolveMatches } from './generateRules' import bigSign from '../util/bigSign' import escapeClassName from '../util/escapeClassName' -function containsBase(selector, classCandidateBase, separator) { - return parser((selectors) => { - let contains = false +function extractClasses(node) { + let classes = new Set() + let container = postcss.root({ nodes: [node.clone()] }) - selectors.walkClasses((classSelector) => { - if (classSelector.value.split(separator).pop() === classCandidateBase) { - contains = true - return false - } - }) + container.walkRules((rule) => { + parser((selectors) => { + selectors.walkClasses((classSelector) => { + classes.add(classSelector.value) + }) + }).processSync(rule.selector) + }) + + return Array.from(classes) +} + +function extractBaseCandidates(candidates, separator) { + let baseClasses = new Set() - return contains - }).transformSync(selector) + for (let candidate of candidates) { + baseClasses.add(candidate.split(separator).pop()) + } + + return Array.from(baseClasses) } function prefix(context, selector) { @@ -212,15 +223,40 @@ function processApply(root, context) { let siblings = [] for (let [applyCandidate, important, rules] of candidates) { - let base = applyCandidate.split(context.tailwindConfig.separator).pop() - for (let [meta, node] of rules) { - if ( - containsBase(parent.selector, base, context.tailwindConfig.separator) && - containsBase(node.selector, base, context.tailwindConfig.separator) - ) { + let parentClasses = extractClasses(parent) + let nodeClasses = extractClasses(node) + + // Add base utility classes from the @apply node to the list of + // classes to check whether it intersects and therefore results in a + // circular dependency or not. + // + // E.g.: + // .foo { + // @apply hover:a; // This applies "a" but with a modifier + // } + // + // We only have to do that with base classes of the `node`, not of the `parent` + // E.g.: + // .hover\:foo { + // @apply bar; + // } + // .bar { + // @apply foo; + // } + // + // This should not result in a circular dependency because we are + // just applying `.foo` and the rule above is `.hover\:foo` which is + // unrelated. However, if we were to apply `hover:foo` then we _did_ + // have to include this one. + nodeClasses = nodeClasses.concat( + extractBaseCandidates(nodeClasses, context.tailwindConfig.separator) + ) + + let intersects = parentClasses.some((selector) => nodeClasses.includes(selector)) + if (intersects) { throw node.error( - `Circular dependency detected when using: \`@apply ${applyCandidate}\`` + `You cannot \`@apply\` the \`${applyCandidate}\` utility here because it creates a circular dependency.` ) } @@ -230,6 +266,42 @@ function processApply(root, context) { if (canRewriteSelector) { root.walkRules((rule) => { + // Let's imagine you have the following structure: + // + // .foo { + // @apply bar; + // } + // + // @supports (a: b) { + // .bar { + // color: blue + // } + // + // .something-unrelated {} + // } + // + // In this case we want to apply `.bar` but it happens to be in + // an atrule node. We clone that node instead of the nested one + // because we still want that @supports rule to be there once we + // applied everything. + // + // However it happens to be that the `.something-unrelated` is + // also in that same shared @supports atrule. This is not good, + // and this should not be there. The good part is that this is + // a clone already and it can be safely removed. The question is + // how do we know we can remove it. Basically what we can do is + // match it against the applyCandidate that you want to apply. If + // it doesn't match the we can safely delete it. + // + // If we didn't do this, then the `replaceSelector` function + // would have replaced this with something that didn't exist and + // therefore it removed the selector altogether. In this specific + // case it would result in `{}` instead of `.something-unrelated {}` + if (!extractClasses(rule).some((thing) => thing === applyCandidate)) { + rule.remove() + return + } + rule.selector = replaceSelector(parent.selector, rule.selector, applyCandidate) rule.walkDecls((d) => { @@ -250,7 +322,6 @@ function processApply(root, context) { // Inject the rules, sorted, correctly let nodes = siblings.sort(([a], [z]) => bigSign(a.sort - z.sort)).map((s) => s[1]) - // console.log(parent) // `parent` refers to the node at `.abc` in: .abc { @apply mt-2 } parent.after(nodes) } diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js index 087a671ea17a..addf2c666169 100644 --- a/src/lib/expandTailwindAtRules.js +++ b/src/lib/expandTailwindAtRules.js @@ -3,33 +3,12 @@ import * as sharedState from './sharedState' import { generateRules } from './generateRules' import bigSign from '../util/bigSign' import cloneNodes from '../util/cloneNodes' +import { defaultExtractor } from './defaultExtractor' let env = sharedState.env -const PATTERNS = [ - /([^<>"'`\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]*'\])/.source, // `content-['hello']` but not `content-['hello']']` - /([^<>"'`\s]*\["[^"'`\s]*"\])/.source, // `content-["hello"]` but not `content-["hello"]"]` - /([^<>"'`\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, // `px-1.5`, `uppercase` but not `uppercase:` -].join('|') -const BROAD_MATCH_GLOBAL_REGEXP = new RegExp(PATTERNS, 'g') -const INNER_MATCH_GLOBAL_REGEXP = /[^<>"'`\s.(){}[\]#=%]*[^<>"'`\s.(){}[\]#=%:]/g - const builtInExtractors = { - DEFAULT: (content) => { - let broadMatches = content.match(BROAD_MATCH_GLOBAL_REGEXP) || [] - let innerMatches = content.match(INNER_MATCH_GLOBAL_REGEXP) || [] - - return [...broadMatches, ...innerMatches] - }, + DEFAULT: defaultExtractor, } const builtInTransformers = { diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index be479c4b6487..150bf6851732 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -112,6 +112,11 @@ function applyVariant(variant, matches, context) { let result = [] for (let [meta, rule] of matches) { + // Don't generate variants for user css + if (meta.layer === 'user') { + continue + } + let container = postcss.root({ nodes: [rule.clone()] }) for (let [variantSort, variantFunction] of variantFunctionTuples) { diff --git a/src/util/resolveConfig.js b/src/util/resolveConfig.js index 3703f56fc46e..15b876e88ff0 100644 --- a/src/util/resolveConfig.js +++ b/src/util/resolveConfig.js @@ -6,6 +6,8 @@ import colors from '../public/colors' import { defaults } from './defaults' import { toPath } from './toPath' import { normalizeConfig } from './normalizeConfig' +import isPlainObject from './isPlainObject' +import { cloneDeep } from './cloneDeep' function isFunction(input) { return typeof input === 'function' @@ -144,7 +146,15 @@ function resolveFunctionKeys(object) { val = isFunction(val) ? val(resolvePath, configUtils) : val } - return val === undefined ? defaultValue : val + if (val === undefined) { + return defaultValue + } + + if (isPlainObject(val)) { + return cloneDeep(val) + } + + return val } resolvePath.theme = resolvePath diff --git a/tests/apply.test.js b/tests/apply.test.js index b4932a702c40..c74bb2875f5d 100644 --- a/tests/apply.test.js +++ b/tests/apply.test.js @@ -484,7 +484,9 @@ it('should throw when trying to apply a direct circular dependency', () => { ` return run(input, config).catch((err) => { - expect(err.reason).toBe('Circular dependency detected when using: `@apply text-red-500`') + expect(err.reason).toBe( + 'You cannot `@apply` the `text-red-500` utility here because it creates a circular dependency.' + ) }) }) @@ -514,7 +516,39 @@ it('should throw when trying to apply an indirect circular dependency', () => { ` return run(input, config).catch((err) => { - expect(err.reason).toBe('Circular dependency detected when using: `@apply a`') + expect(err.reason).toBe( + 'You cannot `@apply` the `a` utility here because it creates a circular dependency.' + ) + }) +}) + +it('should not throw when the selector is different (but contains the base partially)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .focus\:bg-gray-500 { + @apply bg-gray-500; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); + } + + .focus\:bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); + } + `) }) }) @@ -544,7 +578,9 @@ it('should throw when trying to apply an indirect circular dependency with a mod ` return run(input, config).catch((err) => { - expect(err.reason).toBe('Circular dependency detected when using: `@apply hover:a`') + expect(err.reason).toBe( + 'You cannot `@apply` the `hover:a` utility here because it creates a circular dependency.' + ) }) }) @@ -574,7 +610,9 @@ it('should throw when trying to apply an indirect circular dependency with a mod ` return run(input, config).catch((err) => { - expect(err.reason).toBe('Circular dependency detected when using: `@apply a`') + expect(err.reason).toBe( + 'You cannot `@apply` the `a` utility here because it creates a circular dependency.' + ) }) }) @@ -616,3 +654,123 @@ it('rules with vendor prefixes are still separate when optimizing defaults rules `) }) }) + +it('should be possible to apply user css', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .foo { + color: red; + } + + .bar { + @apply foo; + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .foo { + color: red; + } + + .bar { + color: red; + } + `) + }) +}) + +it('should not be possible to apply user css with variants', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .foo { + color: red; + } + + .bar { + @apply hover:foo; + } + ` + + return run(input, config).catch((err) => { + expect(err.reason).toBe( + 'The `hover:foo` class does not exist. If `hover:foo` is a custom class, make sure it is defined within a `@layer` directive.' + ) + }) +}) + +it('should not apply unrelated siblings when applying something from within atrules', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + @layer components { + .foo { + font-weight: bold; + @apply bar; + } + + .bar { + color: green; + } + + @supports (a: b) { + .bar { + color: blue; + } + + .something-unrelated { + color: red; + } + } + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .foo { + font-weight: bold; + color: green; + } + + @supports (a: b) { + .foo { + color: blue; + } + } + + .bar { + color: green; + } + + @supports (a: b) { + .bar { + color: blue; + } + + .something-unrelated { + color: red; + } + } + `) + }) +}) diff --git a/tests/basic-usage.test.js b/tests/basic-usage.test.js index 3bb5bf3741c8..2058a92cbe6c 100644 --- a/tests/basic-usage.test.js +++ b/tests/basic-usage.test.js @@ -57,3 +57,55 @@ test('all plugins are executed that match a candidate', () => { `) }) }) + +test('per-plugin colors with the same key can differ when using a custom colors object', () => { + let config = { + content: [ + { + raw: html` +
This should be green text on red background.
+ `, + }, + ], + theme: { + // colors & theme MUST be plain objects + // If they're functions here the test passes regardless + colors: { + theme: { + bg: 'red', + text: 'green', + }, + }, + extend: { + textColor: { + theme: { + DEFAULT: 'green', + }, + }, + backgroundColor: { + theme: { + DEFAULT: 'red', + }, + }, + }, + }, + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .bg-theme { + --tw-bg-opacity: 1; + background-color: rgb(255 0 0 / var(--tw-bg-opacity)); + } + .text-theme { + --tw-text-opacity: 1; + color: rgb(0 128 0 / var(--tw-text-opacity)); + } + `) + }) +}) diff --git a/tests/default-extractor.test.js b/tests/default-extractor.test.js new file mode 100644 index 000000000000..5c128f64274c --- /dev/null +++ b/tests/default-extractor.test.js @@ -0,0 +1,111 @@ +import { html } from './util/run' +import { defaultExtractor } from '../src/lib/defaultExtractor' + +let jsxExample = "
" +const input = + html` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +` + jsxExample + +const includes = [ + `font-['some_font',sans-serif]`, + `font-["some_font",sans-serif]`, + `bg-[url('...')]`, + `bg-[url("...")]`, + `bg-[url('...'),url('...')]`, + `bg-[url("..."),url("...")]`, + `content-['hello']`, + `content-["hello"]`, + `[content:'hello']`, + `[content:"hello"]`, + `[content:"hello"]`, + `[content:'hello']`, + `fill-[#bada55]`, + `fill-[#bada55]/50`, + `px-1.5`, + `uppercase`, + `hover:font-bold`, + `text-sm`, + `text-[10px]`, + `text-[11px]`, + `text-blue-500`, + `text-[21px]`, + `text-[22px]`, + `text-[31px]`, + `text-[32px]`, + `text-[41px]`, + `text-[42px]`, + `text-[51px]`, + `text-[52px]`, + `text-[61px]`, + `text-[62px]`, + `text-[71px]`, + `text-[72px]`, + `lg:text-[4px]`, + `lg:text-[24px]`, + `content-['>']`, + `hover:test`, + `overflow-scroll`, +] + +const excludes = [ + `uppercase:`, + 'hover:', + "hover:'abc", + `font-bold`, + `
`, + `test`, +] + +test('The default extractor works as expected', async () => { + const extractions = defaultExtractor(input.trim()) + + for (const str of includes) { + expect(extractions).toContain(str) + } + + for (const str of excludes) { + expect(extractions).not.toContain(str) + } +}) diff --git a/tests/variants.test.js b/tests/variants.test.js index 16f3c566fda4..a3bd43e44d68 100644 --- a/tests/variants.test.js +++ b/tests/variants.test.js @@ -421,3 +421,27 @@ test('before and after variants are a bit special, and forced to the end (2)', ( `) }) }) + +it('should not generate variants of user css if it is not inside a layer', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind components; + @tailwind utilities; + + .foo { + color: red; + } + ` + + return run(input, config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .foo { + color: red; + } + `) + }) +})