Skip to content

Commit d261531

Browse files
authored
Add support for arbitrary properties (#6161)
* Basic implementation + some failing tests for edge cases * Use asClass instead of nameClass * Solve edge cases around content with colons * Avoid duplicating work when parsing arbitrary properties * Update changelog
1 parent 56c1646 commit d261531

7 files changed

+337
-59
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Add `placeholder` variant ([#6106](https://github.com/tailwindlabs/tailwindcss/pull/6106))
1717
- Add tuple syntax for configuring screens while guaranteeing order ([#5956](https://github.com/tailwindlabs/tailwindcss/pull/5956))
1818
- Add combinable `touch-action` support ([#6115](https://github.com/tailwindlabs/tailwindcss/pull/6115))
19+
- Add support for "arbitrary properties" ([#6161](https://github.com/tailwindlabs/tailwindcss/pull/6161))
1920

2021
## [3.0.0-alpha.2] - 2021-11-08
2122

src/lib/expandTailwindAtRules.js

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const PATTERNS = [
1515
/([^<>"'`\s]*\[\w*\("[^'`\s]*"\)\])/.source, // bg-[url("..."),url("...")]
1616
/([^<>"'`\s]*\['[^"'`\s]*'\])/.source, // `content-['hello']` but not `content-['hello']']`
1717
/([^<>"'`\s]*\["[^"'`\s]*"\])/.source, // `content-["hello"]` but not `content-["hello"]"]`
18+
/([^<>"'`\s]*\[[^<>"'`\s]*:'[^"'`\s]*'\])/.source, // `[content:'hello']` but not `[content:"hello"]`
19+
/([^<>"'`\s]*\[[^<>"'`\s]*:"[^"'`\s]*"\])/.source, // `[content:"hello"]` but not `[content:'hello']`
1820
/([^<>"'`\s]*\[[^"'`\s]+\][^<>"'`\s]*)/.source, // `fill-[#bada55]`, `fill-[#bada55]/50`
1921
/([^<>"'`\s]*[^"'`\s:])/.source, // `px-1.5`, `uppercase` but not `uppercase:`
2022
].join('|')

src/lib/generateRules.js

+34
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import prefixSelector from '../util/prefixSelector'
66
import { updateAllClasses } from '../util/pluginUtils'
77
import log from '../util/log'
88
import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
9+
import { asClass } from '../util/nameClass'
10+
import { normalize } from '../util/dataTypes'
11+
import isValidArbitraryValue from '../util/isValidArbitraryValue'
912

1013
let classNameParser = selectorParser((selectors) => {
1114
return selectors.first.filter(({ type }) => type === 'class').pop().value
@@ -245,11 +248,42 @@ function parseRules(rule, cache, options = {}) {
245248
return [cache.get(rule), options]
246249
}
247250

251+
function extractArbitraryProperty(classCandidate, context) {
252+
let [, property, value] = classCandidate.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? []
253+
254+
if (value === undefined) {
255+
return null
256+
}
257+
258+
let normalized = normalize(value)
259+
260+
if (!isValidArbitraryValue(normalized)) {
261+
return null
262+
}
263+
264+
return [
265+
[
266+
{ sort: context.arbitraryPropertiesSort, layer: 'utilities' },
267+
() => ({
268+
[asClass(classCandidate)]: {
269+
[property]: normalized,
270+
},
271+
}),
272+
],
273+
]
274+
}
275+
248276
function* resolveMatchedPlugins(classCandidate, context) {
249277
if (context.candidateRuleMap.has(classCandidate)) {
250278
yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT']
251279
}
252280

281+
yield* (function* (arbitraryPropertyRule) {
282+
if (arbitraryPropertyRule !== null) {
283+
yield [arbitraryPropertyRule, 'DEFAULT']
284+
}
285+
})(extractArbitraryProperty(classCandidate, context))
286+
253287
let candidatePrefix = classCandidate
254288
let negative = false
255289

src/lib/setupContextUtils.js

+5-58
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { env } from './sharedState'
1818
import { toPath } from '../util/toPath'
1919
import log from '../util/log'
2020
import negateValue from '../util/negateValue'
21+
import isValidArbitraryValue from '../util/isValidArbitraryValue'
2122

2223
function parseVariantFormatString(input) {
2324
if (input.includes('{')) {
@@ -130,64 +131,6 @@ function withIdentifiers(styles) {
130131
})
131132
}
132133

133-
let matchingBrackets = new Map([
134-
['{', '}'],
135-
['[', ']'],
136-
['(', ')'],
137-
])
138-
let inverseMatchingBrackets = new Map(
139-
Array.from(matchingBrackets.entries()).map(([k, v]) => [v, k])
140-
)
141-
142-
let quotes = new Set(['"', "'", '`'])
143-
144-
// Arbitrary values must contain balanced brackets (), [] and {}. Escaped
145-
// values don't count, and brackets inside quotes also don't count.
146-
//
147-
// E.g.: w-[this-is]w-[weird-and-invalid]
148-
// E.g.: w-[this-is\\]w-\\[weird-but-valid]
149-
// E.g.: content-['this-is-also-valid]-weirdly-enough']
150-
function isValidArbitraryValue(value) {
151-
let stack = []
152-
let inQuotes = false
153-
154-
for (let i = 0; i < value.length; i++) {
155-
let char = value[i]
156-
157-
// Non-escaped quotes allow us to "allow" anything in between
158-
if (quotes.has(char) && value[i - 1] !== '\\') {
159-
inQuotes = !inQuotes
160-
}
161-
162-
if (inQuotes) continue
163-
if (value[i - 1] === '\\') continue // Escaped
164-
165-
if (matchingBrackets.has(char)) {
166-
stack.push(char)
167-
} else if (inverseMatchingBrackets.has(char)) {
168-
let inverse = inverseMatchingBrackets.get(char)
169-
170-
// Nothing to pop from, therefore it is unbalanced
171-
if (stack.length <= 0) {
172-
return false
173-
}
174-
175-
// Popped value must match the inverse value, otherwise it is unbalanced
176-
if (stack.pop() !== inverse) {
177-
return false
178-
}
179-
}
180-
}
181-
182-
// If there is still something on the stack, it is also unbalanced
183-
if (stack.length > 0) {
184-
return false
185-
}
186-
187-
// All good, totally balanced!
188-
return true
189-
}
190-
191134
function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) {
192135
function getConfigValue(path, defaultValue) {
193136
return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
@@ -617,6 +560,10 @@ function registerPlugins(plugins, context) {
617560
])
618561
let reservedBits = BigInt(highestOffset.toString(2).length)
619562

563+
// A number one less than the top range of the highest offset area
564+
// so arbitrary properties are always sorted at the end.
565+
context.arbitraryPropertiesSort = ((1n << reservedBits) << 0n) - 1n
566+
620567
context.layerOrder = {
621568
base: (1n << reservedBits) << 0n,
622569
components: (1n << reservedBits) << 1n,

src/util/isValidArbitraryValue.js

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
let matchingBrackets = new Map([
2+
['{', '}'],
3+
['[', ']'],
4+
['(', ')'],
5+
])
6+
let inverseMatchingBrackets = new Map(
7+
Array.from(matchingBrackets.entries()).map(([k, v]) => [v, k])
8+
)
9+
10+
let quotes = new Set(['"', "'", '`'])
11+
12+
// Arbitrary values must contain balanced brackets (), [] and {}. Escaped
13+
// values don't count, and brackets inside quotes also don't count.
14+
//
15+
// E.g.: w-[this-is]w-[weird-and-invalid]
16+
// E.g.: w-[this-is\\]w-\\[weird-but-valid]
17+
// E.g.: content-['this-is-also-valid]-weirdly-enough']
18+
export default function isValidArbitraryValue(value) {
19+
let stack = []
20+
let inQuotes = false
21+
22+
for (let i = 0; i < value.length; i++) {
23+
let char = value[i]
24+
25+
if (char === ':' && !inQuotes && stack.length === 0) {
26+
return false
27+
}
28+
29+
// Non-escaped quotes allow us to "allow" anything in between
30+
if (quotes.has(char) && value[i - 1] !== '\\') {
31+
inQuotes = !inQuotes
32+
}
33+
34+
if (inQuotes) continue
35+
if (value[i - 1] === '\\') continue // Escaped
36+
37+
if (matchingBrackets.has(char)) {
38+
stack.push(char)
39+
} else if (inverseMatchingBrackets.has(char)) {
40+
let inverse = inverseMatchingBrackets.get(char)
41+
42+
// Nothing to pop from, therefore it is unbalanced
43+
if (stack.length <= 0) {
44+
return false
45+
}
46+
47+
// Popped value must match the inverse value, otherwise it is unbalanced
48+
if (stack.pop() !== inverse) {
49+
return false
50+
}
51+
}
52+
}
53+
54+
// If there is still something on the stack, it is also unbalanced
55+
if (stack.length > 0) {
56+
return false
57+
}
58+
59+
// All good, totally balanced!
60+
return true
61+
}

src/util/nameClass.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import escapeClassName from './escapeClassName'
22
import escapeCommas from './escapeCommas'
33

4-
function asClass(name) {
4+
export function asClass(name) {
55
return escapeCommas(`.${escapeClassName(name)}`)
66
}
77

0 commit comments

Comments
 (0)