From 0d2de6b40b8f791637293d11b4c7a5892ff72a88 Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Sat, 20 Nov 2021 10:09:11 -0500 Subject: [PATCH 1/5] Basic implementation + some failing tests for edge cases --- src/lib/generateRules.js | 31 +++- src/lib/setupContextUtils.js | 4 + tests/arbitrary-properties.test.js | 233 +++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 tests/arbitrary-properties.test.js diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index cdb5294769f6..b3beb87ad7e9 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -6,6 +6,8 @@ import prefixSelector from '../util/prefixSelector' import { updateAllClasses } from '../util/pluginUtils' import log from '../util/log' import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector' +import nameClass from '../util/nameClass' +import { normalize } from '../util/dataTypes' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value @@ -245,11 +247,39 @@ function parseRules(rule, cache, options = {}) { return [cache.get(rule), options] } +function isArbitraryProperty(classCandidate) { + return classCandidate.match(/^\[[a-zA-Z0-9-_]+:\S+\]$/) !== null +} + +function resolveArbitraryProperty(classCandidate, context) { + let declaration = classCandidate.slice(1, -1) + let property = declaration.substr(0, declaration.indexOf(':')) + let value = declaration.substr(declaration.indexOf(':') + 1) + + return [ + [ + [ + { sort: context.arbitraryPropertiesSort, layer: 'utilities' }, + () => ({ + [nameClass(classCandidate, 'DEFAULT')]: { + [property]: normalize(value), + }, + }), + ], + ], + 'DEFAULT', + ] +} + function* resolveMatchedPlugins(classCandidate, context) { if (context.candidateRuleMap.has(classCandidate)) { yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT'] } + if (isArbitraryProperty(classCandidate)) { + yield resolveArbitraryProperty(classCandidate, context) + } + let candidatePrefix = classCandidate let negative = false @@ -308,7 +338,6 @@ function* resolveMatches(candidate, context) { 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) diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 2701adb9a3a2..6f3db0402050 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -617,6 +617,10 @@ function registerPlugins(plugins, context) { ]) let reservedBits = BigInt(highestOffset.toString(2).length) + // A number one less than the top range of the highest offset area + // so arbitrary properties are always sorted at the end. + context.arbitraryPropertiesSort = ((1n << reservedBits) << 0n) - 1n + context.layerOrder = { base: (1n << reservedBits) << 0n, components: (1n << reservedBits) << 1n, diff --git a/tests/arbitrary-properties.test.js b/tests/arbitrary-properties.test.js new file mode 100644 index 000000000000..ee9fd63882fa --- /dev/null +++ b/tests/arbitrary-properties.test.js @@ -0,0 +1,233 @@ +import { run, html, css } from './util/run' + +test('basic arbitrary properties', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .\[paint-order\:markers\] { + paint-order: markers; + } + `) + }) +}) + +test('arbitrary properties with modifiers', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (prefers-color-scheme: dark) { + @media (min-width: 1024px) { + .\[paint-order\:markers\] { + paint-order: markers; + } + } + } + `) + }) +}) + +test('arbitrary properties are sorted after utilities', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .content-none { + --tw-content: none; + content: var(--tw-content); + } + .\[paint-order\:markers\] { + paint-order: markers; + } + .hover\:pointer-events-none:hover { + pointer-events: none; + } + `) + }) +}) + +test('using CSS variables', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .\[--my-var\:auto\] { + --my-var: auto; + } + `) + }) +}) + +test('using underscores as spaces', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .\[--my-var\:2px_4px\] { + --my-var: 2px 4px; + } + `) + }) +}) + +test('using the important modifier', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .\!\[--my-var\:2px_4px\] { + --my-var: 2px 4px !important; + } + `) + }) +}) + +test('colons are allowed in quotes', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .\[a\'\:b\:c\:d\'\] { + a: 'b:c:d'; + } + `) + }) +}) + +test('colons are allowed in braces', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .\[a\(\:b\:c\:d\)\] { + a: (b: c: d); + } + `) + }) +}) + +test('invalid class', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind base; + @tailwind components; + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css``) + }) +}) From 70aed40fa16b53c0f74ec2335556f4c15d489569 Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Sat, 20 Nov 2021 11:53:51 -0500 Subject: [PATCH 2/5] Use asClass instead of nameClass --- src/lib/generateRules.js | 19 ++++++++----------- src/util/nameClass.js | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index b3beb87ad7e9..395b8f04dea0 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -6,7 +6,7 @@ import prefixSelector from '../util/prefixSelector' import { updateAllClasses } from '../util/pluginUtils' import log from '../util/log' import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector' -import nameClass from '../util/nameClass' +import { asClass } from '../util/nameClass' import { normalize } from '../util/dataTypes' let classNameParser = selectorParser((selectors) => { @@ -258,16 +258,13 @@ function resolveArbitraryProperty(classCandidate, context) { return [ [ - [ - { sort: context.arbitraryPropertiesSort, layer: 'utilities' }, - () => ({ - [nameClass(classCandidate, 'DEFAULT')]: { - [property]: normalize(value), - }, - }), - ], + { sort: context.arbitraryPropertiesSort, layer: 'utilities' }, + () => ({ + [asClass(classCandidate)]: { + [property]: normalize(value), + }, + }), ], - 'DEFAULT', ] } @@ -277,7 +274,7 @@ function* resolveMatchedPlugins(classCandidate, context) { } if (isArbitraryProperty(classCandidate)) { - yield resolveArbitraryProperty(classCandidate, context) + yield [resolveArbitraryProperty(classCandidate, context), 'DEFAULT'] } let candidatePrefix = classCandidate diff --git a/src/util/nameClass.js b/src/util/nameClass.js index e3a40f8eeae3..ae737012901b 100644 --- a/src/util/nameClass.js +++ b/src/util/nameClass.js @@ -1,7 +1,7 @@ import escapeClassName from './escapeClassName' import escapeCommas from './escapeCommas' -function asClass(name) { +export function asClass(name) { return escapeCommas(`.${escapeClassName(name)}`) } From 62bfefcb1b8bd4a4438a4e2793051d63ffeb228b Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Sat, 20 Nov 2021 16:39:53 -0500 Subject: [PATCH 3/5] Solve edge cases around content with colons --- src/lib/expandTailwindAtRules.js | 2 + src/lib/generateRules.js | 14 +++++-- src/lib/setupContextUtils.js | 59 +---------------------------- src/util/isValidArbitraryValue.js | 61 ++++++++++++++++++++++++++++++ tests/arbitrary-properties.test.js | 12 +++--- 5 files changed, 81 insertions(+), 67 deletions(-) create mode 100644 src/util/isValidArbitraryValue.js diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js index 14a1c8af44e3..4b1c627088b8 100644 --- a/src/lib/expandTailwindAtRules.js +++ b/src/lib/expandTailwindAtRules.js @@ -15,6 +15,8 @@ const PATTERNS = [ /([^<>"'`\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('|') diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 395b8f04dea0..5a1276fa71f0 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -8,6 +8,7 @@ import log from '../util/log' import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector' import { asClass } from '../util/nameClass' import { normalize } from '../util/dataTypes' +import isValidArbitraryValue from '../util/isValidArbitraryValue' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value @@ -248,10 +249,16 @@ function parseRules(rule, cache, options = {}) { } function isArbitraryProperty(classCandidate) { - return classCandidate.match(/^\[[a-zA-Z0-9-_]+:\S+\]$/) !== null + let [, value] = classCandidate.match(/^\[[a-zA-Z0-9-_]+:(\S+)\]$/) ?? [] + + if (value === undefined) { + return false + } + + return isValidArbitraryValue(normalize(value)) } -function resolveArbitraryProperty(classCandidate, context) { +function constructArbitraryRule(classCandidate, context) { let declaration = classCandidate.slice(1, -1) let property = declaration.substr(0, declaration.indexOf(':')) let value = declaration.substr(declaration.indexOf(':') + 1) @@ -261,6 +268,7 @@ function resolveArbitraryProperty(classCandidate, context) { { sort: context.arbitraryPropertiesSort, layer: 'utilities' }, () => ({ [asClass(classCandidate)]: { + // TODO: Refactor so we don't call normalize twice [property]: normalize(value), }, }), @@ -274,7 +282,7 @@ function* resolveMatchedPlugins(classCandidate, context) { } if (isArbitraryProperty(classCandidate)) { - yield [resolveArbitraryProperty(classCandidate, context), 'DEFAULT'] + yield [constructArbitraryRule(classCandidate, context), 'DEFAULT'] } let candidatePrefix = classCandidate diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 6f3db0402050..2a7f04772064 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -18,6 +18,7 @@ import { env } from './sharedState' import { toPath } from '../util/toPath' import log from '../util/log' import negateValue from '../util/negateValue' +import isValidArbitraryValue from '../util/isValidArbitraryValue' function parseVariantFormatString(input) { if (input.includes('{')) { @@ -130,64 +131,6 @@ function withIdentifiers(styles) { }) } -let matchingBrackets = new Map([ - ['{', '}'], - ['[', ']'], - ['(', ')'], -]) -let inverseMatchingBrackets = new Map( - Array.from(matchingBrackets.entries()).map(([k, v]) => [v, k]) -) - -let quotes = new Set(['"', "'", '`']) - -// Arbitrary values must contain balanced brackets (), [] and {}. Escaped -// values don't count, and brackets inside quotes also don't count. -// -// E.g.: w-[this-is]w-[weird-and-invalid] -// E.g.: w-[this-is\\]w-\\[weird-but-valid] -// E.g.: content-['this-is-also-valid]-weirdly-enough'] -function isValidArbitraryValue(value) { - let stack = [] - let inQuotes = false - - for (let i = 0; i < value.length; i++) { - let char = value[i] - - // Non-escaped quotes allow us to "allow" anything in between - if (quotes.has(char) && value[i - 1] !== '\\') { - inQuotes = !inQuotes - } - - if (inQuotes) continue - if (value[i - 1] === '\\') continue // Escaped - - if (matchingBrackets.has(char)) { - stack.push(char) - } else if (inverseMatchingBrackets.has(char)) { - let inverse = inverseMatchingBrackets.get(char) - - // Nothing to pop from, therefore it is unbalanced - if (stack.length <= 0) { - return false - } - - // Popped value must match the inverse value, otherwise it is unbalanced - if (stack.pop() !== inverse) { - return false - } - } - } - - // If there is still something on the stack, it is also unbalanced - if (stack.length > 0) { - return false - } - - // All good, totally balanced! - return true -} - function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) { function getConfigValue(path, defaultValue) { return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig diff --git a/src/util/isValidArbitraryValue.js b/src/util/isValidArbitraryValue.js new file mode 100644 index 000000000000..f2b10b51f601 --- /dev/null +++ b/src/util/isValidArbitraryValue.js @@ -0,0 +1,61 @@ +let matchingBrackets = new Map([ + ['{', '}'], + ['[', ']'], + ['(', ')'], +]) +let inverseMatchingBrackets = new Map( + Array.from(matchingBrackets.entries()).map(([k, v]) => [v, k]) +) + +let quotes = new Set(['"', "'", '`']) + +// Arbitrary values must contain balanced brackets (), [] and {}. Escaped +// values don't count, and brackets inside quotes also don't count. +// +// E.g.: w-[this-is]w-[weird-and-invalid] +// E.g.: w-[this-is\\]w-\\[weird-but-valid] +// E.g.: content-['this-is-also-valid]-weirdly-enough'] +export default function isValidArbitraryValue(value) { + let stack = [] + let inQuotes = false + + for (let i = 0; i < value.length; i++) { + let char = value[i] + + if (char === ':' && !inQuotes && stack.length === 0) { + return false + } + + // Non-escaped quotes allow us to "allow" anything in between + if (quotes.has(char) && value[i - 1] !== '\\') { + inQuotes = !inQuotes + } + + if (inQuotes) continue + if (value[i - 1] === '\\') continue // Escaped + + if (matchingBrackets.has(char)) { + stack.push(char) + } else if (inverseMatchingBrackets.has(char)) { + let inverse = inverseMatchingBrackets.get(char) + + // Nothing to pop from, therefore it is unbalanced + if (stack.length <= 0) { + return false + } + + // Popped value must match the inverse value, otherwise it is unbalanced + if (stack.pop() !== inverse) { + return false + } + } + } + + // If there is still something on the stack, it is also unbalanced + if (stack.length > 0) { + return false + } + + // All good, totally balanced! + return true +} diff --git a/tests/arbitrary-properties.test.js b/tests/arbitrary-properties.test.js index ee9fd63882fa..1b98e49d2d6a 100644 --- a/tests/arbitrary-properties.test.js +++ b/tests/arbitrary-properties.test.js @@ -165,7 +165,7 @@ test('colons are allowed in quotes', () => { let config = { content: [ { - raw: html`
`, + raw: html`
`, }, ], corePlugins: { preflight: false }, @@ -179,8 +179,8 @@ test('colons are allowed in quotes', () => { return run(input, config).then((result) => { expect(result.css).toMatchFormattedCss(css` - .\[a\'\:b\:c\:d\'\] { - a: 'b:c:d'; + .\[content\:\'foo\:bar\'\] { + content: 'foo:bar'; } `) }) @@ -190,7 +190,7 @@ test('colons are allowed in braces', () => { let config = { content: [ { - raw: html`
`, + raw: html`
`, }, ], corePlugins: { preflight: false }, @@ -204,8 +204,8 @@ test('colons are allowed in braces', () => { return run(input, config).then((result) => { expect(result.css).toMatchFormattedCss(css` - .\[a\(\:b\:c\:d\)\] { - a: (b: c: d); + .\[background-image\:url\(http\:\/\/example\.com\/picture\.jpg\)\] { + background-image: url(http://example.com/picture.jpg); } `) }) From 5bd61625ecabc9da6a733d96d7f5f38061578cfc Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Sun, 21 Nov 2021 11:07:11 -0500 Subject: [PATCH 4/5] Avoid duplicating work when parsing arbitrary properties --- src/lib/generateRules.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 5a1276fa71f0..521b35025e4a 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -248,28 +248,25 @@ function parseRules(rule, cache, options = {}) { return [cache.get(rule), options] } -function isArbitraryProperty(classCandidate) { - let [, value] = classCandidate.match(/^\[[a-zA-Z0-9-_]+:(\S+)\]$/) ?? [] +function extractArbitraryProperty(classCandidate, context) { + let [, property, value] = classCandidate.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? [] if (value === undefined) { - return false + return null } - return isValidArbitraryValue(normalize(value)) -} + let normalized = normalize(value) -function constructArbitraryRule(classCandidate, context) { - let declaration = classCandidate.slice(1, -1) - let property = declaration.substr(0, declaration.indexOf(':')) - let value = declaration.substr(declaration.indexOf(':') + 1) + if (!isValidArbitraryValue(normalized)) { + return null + } return [ [ { sort: context.arbitraryPropertiesSort, layer: 'utilities' }, () => ({ [asClass(classCandidate)]: { - // TODO: Refactor so we don't call normalize twice - [property]: normalize(value), + [property]: normalized, }, }), ], @@ -281,9 +278,11 @@ function* resolveMatchedPlugins(classCandidate, context) { yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT'] } - if (isArbitraryProperty(classCandidate)) { - yield [constructArbitraryRule(classCandidate, context), 'DEFAULT'] - } + yield* (function* (arbitraryPropertyRule) { + if (arbitraryPropertyRule !== null) { + yield [arbitraryPropertyRule, 'DEFAULT'] + } + })(extractArbitraryProperty(classCandidate, context)) let candidatePrefix = classCandidate let negative = false @@ -343,6 +342,7 @@ function* resolveMatches(candidate, context) { 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) From e3e603e1501f1f0c03099396f254d20825e49a1d Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Mon, 22 Nov 2021 10:58:43 -0500 Subject: [PATCH 5/5] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4de3bc821e6b..cb4b6758f2b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `placeholder` variant ([#6106](https://github.com/tailwindlabs/tailwindcss/pull/6106)) - Add tuple syntax for configuring screens while guaranteeing order ([#5956](https://github.com/tailwindlabs/tailwindcss/pull/5956)) - Add combinable `touch-action` support ([#6115](https://github.com/tailwindlabs/tailwindcss/pull/6115)) +- Add support for "arbitrary properties" ([#6161](https://github.com/tailwindlabs/tailwindcss/pull/6161)) ## [3.0.0-alpha.2] - 2021-11-08