Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for arbitrary properties #6161

Merged
merged 5 commits into from
Nov 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading