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 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 cdb5294769f6..521b35025e4a 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -6,6 +6,9 @@ import prefixSelector from '../util/prefixSelector' import { updateAllClasses } from '../util/pluginUtils' 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 @@ -245,11 +248,42 @@ function parseRules(rule, cache, options = {}) { return [cache.get(rule), options] } +function extractArbitraryProperty(classCandidate, context) { + let [, property, value] = classCandidate.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? [] + + if (value === undefined) { + return null + } + + let normalized = normalize(value) + + if (!isValidArbitraryValue(normalized)) { + return null + } + + return [ + [ + { sort: context.arbitraryPropertiesSort, layer: 'utilities' }, + () => ({ + [asClass(classCandidate)]: { + [property]: normalized, + }, + }), + ], + ] +} + function* resolveMatchedPlugins(classCandidate, context) { if (context.candidateRuleMap.has(classCandidate)) { yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT'] } + yield* (function* (arbitraryPropertyRule) { + if (arbitraryPropertyRule !== null) { + yield [arbitraryPropertyRule, 'DEFAULT'] + } + })(extractArbitraryProperty(classCandidate, context)) + let candidatePrefix = classCandidate let negative = false diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 2701adb9a3a2..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 @@ -617,6 +560,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/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/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)}`) } diff --git a/tests/arbitrary-properties.test.js b/tests/arbitrary-properties.test.js new file mode 100644 index 000000000000..1b98e49d2d6a --- /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` + .\[content\:\'foo\:bar\'\] { + content: 'foo:bar'; + } + `) + }) +}) + +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` + .\[background-image\:url\(http\:\/\/example\.com\/picture\.jpg\)\] { + background-image: url(http://example.com/picture.jpg); + } + `) + }) +}) + +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``) + }) +})