Skip to content

Commit

Permalink
Add support for arbitrary properties (#6161)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
adamwathan committed Nov 22, 2021
1 parent 56c1646 commit d261531
Show file tree
Hide file tree
Showing 7 changed files with 337 additions and 59 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/lib/expandTailwindAtRules.js
Expand Up @@ -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('|')
Expand Down
34 changes: 34 additions & 0 deletions src/lib/generateRules.js
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
63 changes: 5 additions & 58 deletions src/lib/setupContextUtils.js
Expand Up @@ -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('{')) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions 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
}
2 changes: 1 addition & 1 deletion 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)}`)
}

Expand Down

0 comments on commit d261531

Please sign in to comment.