Compare commits

...
Sign in to create a new pull request.

19 commits

Author SHA1 Message Date
Jordan Pittman
880ceb49c9 WIP 2022-05-18 12:05:26 -04:00
Jordan Pittman
b756b00ba1 Fix build 2022-05-18 11:40:02 -04:00
Jordan Pittman
f54152b19e Fix CS 2022-05-18 11:35:53 -04:00
Jordan Pittman
52828f0100 Push candidate object into finalizeSelector 2022-05-18 10:45:01 -04:00
Jordan Pittman
b811c698fe Fix rebase problem 2022-05-18 10:13:51 -04:00
Jordan Pittman
16349e74c4 WIP 2022-05-18 10:12:15 -04:00
Jordan Pittman
cd24c5904b WIP 2022-05-18 10:12:15 -04:00
Jordan Pittman
985e35722b WIP 2022-05-18 10:12:15 -04:00
Jordan Pittman
5bcd9d2f69 WIP 2022-05-18 10:12:15 -04:00
Jordan Pittman
3cb47aaab6 Add test 2022-05-18 10:12:15 -04:00
Jordan Pittman
92000a5820 Stop double-parsing variants 2022-05-18 10:12:15 -04:00
Jordan Pittman
c8be920468 Use candidate object in applyImportant 2022-05-18 10:12:15 -04:00
Jordan Pittman
3192f2a007 Use object in recordCandidates 2022-05-18 10:12:15 -04:00
Jordan Pittman
90209cfda8 Use candidate object in extractArbitraryProperty
By this point arbitrary properties have been validated to be parsable. They aren’t valid candidates if they can’t be parsed (because they won’t produce valid CSS). This means all the checks in here can go away because the candidate parsing has handled it already.
2022-05-18 10:12:15 -04:00
Jordan Pittman
f66873f35e Use parsed object in resolveMatchedPlugins 2022-05-18 10:12:14 -04:00
Jordan Pittman
5fe5a65029 Use new candidate parsing in resolveMatches 2022-05-18 10:12:14 -04:00
Jordan Pittman
0f98fec7f0 Add new candidate object 2022-05-18 10:12:14 -04:00
Jordan Pittman
39c7a93f22 De-nest applyVariant 2022-05-18 10:09:59 -04:00
Jordan Pittman
3ac72b7e7d Move CSS validation helpers to separate file
wip
2022-05-18 10:09:58 -04:00
14 changed files with 1727 additions and 417 deletions

1
.swcrc
View file

@ -1,4 +1,5 @@
{
"exclude": ".d.ts$",
"module": {
"type": "commonjs"
},

65
src/lib/candidate.d.ts vendored Normal file
View file

@ -0,0 +1,65 @@
type Arbitrary = {
raw: string
value: string
dataType: AnyStringWithAutocomplete<DataType>
}
type VariantCommon = { raw: string }
type VariantType =
| { type: 'constrained'; name: string }
| { type: 'partial'; name: string; value: string; dataType: DataType }
| { type: 'custom'; value: string; dataType: DataType }
type Variant = VariantCommon & VariantType
type ModifierCommon = { raw: string }
type ModifierType = { value: string | Arbitrary }
type Modifier = ModifierCommon & ModifierType
type UtilityCommon = {
raw: string
className: string
withoutVariants: string // TODO: remove this
important: boolean
prefix: string
negative: boolean
variants: Variant[]
}
export type Plugin = {
plugin: string
value: string | Arbitrary
modifiers: Modifier[]
}
type UtilityType =
| {
type: 'constrained'
plugins(): Iterable<Plugin>
}
| { type: 'custom'; name: string; value: string; modifiers: Modifier[] }
export type Candidate<T = UtilityType['type']> = UtilityCommon & Extract<UtilityType, { type: T }>
export function parseCandidate(raw: string, context: any): Iterable<Candidate>
export type AnyStringWithAutocomplete<T> = T | (string & Record<never, never>)
export type DataType =
| 'any'
| 'color'
| 'url'
| 'image'
| 'length'
| 'percentage'
| 'position'
| 'lookup'
| 'generic-name'
| 'family-name'
| 'number'
| 'line-width'
| 'absolute-size'
| 'relative-size'
| 'shadow'

495
src/lib/candidate.js Normal file
View file

@ -0,0 +1,495 @@
// @ts-check
/** @typedef {import('./candidate.d').Plugin} Plugin */
/** @typedef {import('./candidate.d').Arbitrary} Arbitrary */
/** @typedef {import('./candidate.d').Candidate} Candidate */
/** @typedef {import('./candidate.d').DataType} DataType */
/** @typedef {import('./candidate.d').Variant} Variant */
/** @typedef {import('./candidate.d').Modifier} Modifier */
import { normalize } from '../util/dataTypes.js'
import isValidArbitraryValue from '../util/isValidArbitraryValue.js'
import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js'
import { isParsableCssValue, isValidPropName, looksLikeUri } from '../util/css-validation.js'
import { candidatePermutations } from '../util/candidatePermutations'
import * as sharedState from './sharedState'
import { asClass } from '../util/nameClass.js'
let caches = {
/** @type {Map<string, Candidate[]>} */
candidates: new Map(),
/** @type {Map<string, Variant>} */
variants: new Map(),
/** @type {Map<string, Modifier>} */
modifiers: new Map(),
/** @type {Map<string, Arbitrary | string>} */
arbitrary: new Map(),
}
/**
*
* @param {string} raw
* @param {any} context
* @returns {Iterable<Candidate>}
*/
export function parseCandidate(raw, context) {
if (caches.candidates.has(raw)) {
return caches.candidates.get(raw)
}
let candidates = Array.from(parseStructure(raw, context))
.map((candidate) => validateCandidate(candidate))
.filter(Boolean)
return put(caches.candidates, raw, candidates)
}
/**
*
* @param {Candidate} candidate
* @returns {Candidate | null}
*/
function validateCandidate(candidate) {
// Normalize arbitrary values in variants
for (const item of candidate.variants) {
if (item.type === 'partial' || item.type === 'custom') {
item.value = normalize(item.value)
}
}
candidate = validateCustomCandidate(candidate)
if (!candidate) {
return candidate
}
if (candidate.type !== 'constrained') {
return candidate
}
// Normalize arbitrary values
let plugins = candidate.plugins
candidate.plugins = () => validatePlugins(plugins())
return candidate
}
/**
*
* @param {Candidate} candidate
* @returns {Candidate | null}
*/
function validateCustomCandidate(candidate) {
if (candidate.type !== 'custom') {
return candidate
}
// Ignore invalid custom property names
if (!isValidPropName(candidate.name)) {
return null
}
// Ignore url-like custom properties
if (looksLikeUri(`${candidate.name}:${candidate.value}`)) {
return null
}
if (!isValidArbitraryValue(candidate.value)) {
return null
}
if (!isParsableCssValue(candidate.name, candidate.value)) {
return null
}
// Normalize arbitrary values in modifiers
candidate.modifiers = Array.from(normalizeModifiers(candidate.modifiers))
candidate.value = normalize(candidate.value)
return candidate
}
/**
*
* @param {Iterable<Modifier>} modifiers
* @returns {Iterable<Modifier>}
*/
function* normalizeModifiers(modifiers) {
// Normalize arbitrary values in modifiers
for (const modifier of modifiers) {
if (typeof modifier.value === 'string') {
modifier.value = normalize(modifier.value)
} else if (typeof modifier.value === 'object') {
modifier.value.value = normalize(modifier.value.value)
}
yield modifier
}
}
/**
*
* @param {Iterable<Plugin>} plugins
*/
function* validatePlugins(plugins) {
for (const plugin of plugins) {
if (typeof plugin.value === 'object') {
if (!isValidArbitraryValue(plugin.value.value)) {
continue
}
plugin.modifiers = Array.from(normalizeModifiers(plugin.modifiers))
plugin.value.value = normalize(plugin.value.value)
}
yield plugin
}
}
/**
*
* @param {string} raw
* @param {any} context
* @returns {Iterable<Candidate>}
*/
function* parseStructure(raw, context) {
// TODO: Can we remove this?
if (raw === sharedState.NOT_ON_DEMAND) {
yield {
raw,
withoutVariants: raw,
className: `${sharedState.NOT_ON_DEMAND}`,
// This is always gonna be the same
// but is included for completeness
prefix: context.tailwindConfig.prefix ?? '',
important: false,
variants: [],
negative: false,
/** @type {'constrained'} */
type: 'constrained',
// @ts-ignore
*plugins() {
yield {
// @ts-ignore: TODO Would love to remove the need for the new String('*) stuff if at all ever maybe possible
plugin: sharedState.NOT_ON_DEMAND,
value: 'DEFAULT',
modifiers: [],
}
},
}
return
}
// Parse out the variants
let [candidate, ...rawVariants] = Array.from(
splitAtTopLevelOnly(raw, context.tailwindConfig.separator)
).reverse()
let variants = rawVariants.map(parseVariant)
// Important?
let important = candidate[0] === '!'
if (important) {
candidate = candidate.slice(1)
}
let withoutVariants = candidate
// Negative before prefix
let negative = false
if (candidate[0] === '-') {
negative = true
candidate = candidate.slice(1)
}
// Verify the prefix
// If the candidate isn't prefixed with the configured prefix
// Then we treat the entire thing as the candidate rather than just the prefixed portion
let prefix = context.tailwindConfig.prefix ?? ''
let prefixLength = prefix.length
if (prefix !== '') {
if (candidate.slice(0, prefixLength) === prefix) {
candidate = candidate.slice(prefixLength)
} else {
prefix = ''
prefixLength = 0
}
}
// Negative after prefix
if (prefix !== '' && candidate[0] === '-') {
// We had a negative before the prefix
// This means we're looking at something like -tw--top-1
// That's weird and invalid
if (negative === true) {
return
}
negative = true
candidate = candidate.slice(1)
}
let common = {
raw,
className: asClass(raw),
withoutVariants,
prefix,
important,
variants,
negative,
}
let arbitraryProperty = parseArbitraryProperty(candidate)
if (arbitraryProperty) {
yield Object.assign({}, common, {
/** @type {'custom'} */
type: 'custom',
name: arbitraryProperty[0],
value: arbitraryProperty[1],
modifiers: [],
})
return
}
// Scan for the name up to the modifier, opening arbitrary value bracket, or end of string
yield Object.assign({}, common, {
/** @type {'constrained'} */
type: 'constrained',
plugins: () => {
// Generate each plugin
let plugins = generatePlugins(candidate, negative)
// Insert modifier versions of each plugin (if necessary)
plugins = insertModifierPlugins(plugins, parseModifiers(candidate))
return plugins
}
})
}
/**
*
* @param {string} candidate
* @param {boolean} negative
* @returns {Iterable<Plugin>}
*/
function* generatePlugins(candidate, negative) {
for (const plugin of candidatePermutations(candidate, negative)) {
yield plugin
let arbitrary = extractArbitraryValue(`${plugin.value}`)
if (arbitrary) {
yield {
...plugin,
value: arbitrary,
}
}
}
}
/**
*
* @param {Iterable<Plugin>} plugins
* @param {Modifier[]} modifiers
* @returns {Iterable<Plugin>}
*/
function* insertModifierPlugins(plugins, modifiers) {
for (const plugin of plugins) {
if (typeof plugin.value === 'object') {
plugin.modifiers = modifiers
}
yield plugin
if (modifiers.length === 0) {
continue
}
if (typeof plugin.value === 'string' && plugin.value.includes(`/${modifiers[0].raw}`)) {
yield {
...plugin,
value: plugin.value.slice(0, plugin.value.indexOf(`/${modifiers[0].raw}`)),
modifiers,
}
}
}
}
/**
*
* @param {string} str
* @returns {[string, string] | null}
*/
function parseArbitraryProperty(str) {
let [, property, value] = str.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? []
if (value === undefined) {
return null
}
return [property, value]
}
/**
* @param {string} raw
* @returns {Arbitrary | null}
*/
function extractArbitraryValue(raw) {
let arbitraryStart = raw.indexOf('[')
let arbitraryEnd = raw.lastIndexOf(']/')
if (arbitraryEnd === -1) {
arbitraryEnd = raw.lastIndexOf(']')
}
if (arbitraryStart === -1 || arbitraryEnd === -1) {
return null
}
let arbitrary = parseArbitraryValue(raw.slice(arbitraryStart, arbitraryEnd + 1))
if (typeof arbitrary === 'string') {
return null
}
return arbitrary
}
/**
* @param {string} raw
* @returns {string | Arbitrary}
*/
function parseArbitraryValue(raw) {
if (caches.arbitrary.has(raw)) {
return caches.arbitrary.get(raw)
}
if (raw[0] !== '[' || raw[raw.length - 1] !== ']') {
return raw
}
let dataTypeSeparator = raw.indexOf(':')
let dataType = raw.slice(1, dataTypeSeparator)
let isValidDataType = dataTypeSeparator !== -1 && /^[\w-_]+$/g.test(dataType)
let value = raw.slice(dataTypeSeparator + 1, -1)
// It could also be that the user says something like bg-[color:]
// This would be treated as an "empty" arbitrary value with type of `color` which is not what we want
// This this is not really an arbitrary value but just a raw string
if (value === '') {
return raw
}
// It could be that this resolves to `url(https` which is not a valid
// identifier. We currently only support "simple" words with dashes or
// underscores. E.g.: family-name
if (!isValidDataType) {
return put(caches.arbitrary, raw, {
raw,
value: raw.slice(1, -1),
dataType: 'any',
})
}
return put(caches.arbitrary, raw, {
raw,
value,
dataType,
})
}
/**
*
* @param {string} raw
* @returns {Variant}
*/
function parseVariant(raw) {
if (caches.variants.has(raw)) {
return caches.variants.get(raw)
}
if (raw[0] === '[' && raw[raw.length - 1] === ']') {
return put(caches.variants, raw, {
/** @type {'custom'} */
type: 'custom',
raw,
value: raw.slice(1, -1),
dataType: 'any',
})
}
if (raw[0] !== '[' && raw[raw.length - 1] === ']') {
let value = raw.slice(raw.lastIndexOf('[') + 1, -1)
let name = raw.slice(0, raw.indexOf(value) - 1 /* - */ - 1 /* [ */)
return put(caches.variants, raw, {
type: 'partial',
raw,
name,
value,
dataType: 'any',
})
}
return put(caches.variants, raw, {
type: 'constrained',
raw,
name: raw,
})
}
/**
*
* @param {string} raw
* @returns {Modifier[]}
*/
function parseModifiers(raw) {
let match = raw.match(/\/(\[[^\[\]]+\])|\/([^\[\]]+)$/) ?? []
if (match.length === 0) {
return []
}
if (match[1] !== undefined) {
return [
{
raw: match[1],
value: parseArbitraryValue(match[1]),
},
]
}
if (match[2] !== undefined) {
return [
{
raw: match[2],
value: match[2],
},
]
}
return null
}
/**
* @template T
* @param {Map<string, T>} cache
* @param {string} key
* @param {T} value
* @returns {T}
*/
function put(cache, key, value) {
cache.set(key, value)
// console.log('CACHE MISS', { key, value })
return value
}

View file

@ -5,13 +5,11 @@ import isPlainObject from '../util/isPlainObject'
import prefixSelector from '../util/prefixSelector'
import { updateAllClasses } from '../util/pluginUtils'
import log from '../util/log'
import * as sharedState from './sharedState'
import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
import { asClass } from '../util/nameClass'
import { normalize } from '../util/dataTypes'
import { isValidVariantFormatString, parseVariant } from './setupContextUtils'
import isValidArbitraryValue from '../util/isValidArbitraryValue'
import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js'
import { isParsableNode } from '../util/css-validation'
import { parseCandidate } from './candidate'
let classNameParser = selectorParser((selectors) => {
return selectors.first.filter(({ type }) => type === 'class').pop().value
@ -21,42 +19,6 @@ function getClassNameFromSelector(selector) {
return classNameParser.transformSync(selector)
}
// Generate match permutations for a class candidate, like:
// ['ring-offset-blue', '100']
// ['ring-offset', 'blue-100']
// ['ring', 'offset-blue-100']
// Example with dynamic classes:
// ['grid-cols', '[[linename],1fr,auto]']
// ['grid', 'cols-[[linename],1fr,auto]']
function* candidatePermutations(candidate) {
let lastIndex = Infinity
while (lastIndex >= 0) {
let dashIdx
if (lastIndex === Infinity && candidate.endsWith(']')) {
let bracketIdx = candidate.indexOf('[')
// If character before `[` isn't a dash or a slash, this isn't a dynamic class
// eg. string[]
dashIdx = ['-', '/'].includes(candidate[bracketIdx - 1]) ? bracketIdx - 1 : -1
} else {
dashIdx = candidate.lastIndexOf('-', lastIndex)
}
if (dashIdx < 0) {
break
}
let prefix = candidate.slice(0, dashIdx)
let modifier = candidate.slice(dashIdx + 1)
yield [prefix, modifier]
lastIndex = dashIdx - 1
}
}
function applyPrefix(matches, context) {
if (matches.length === 0 || context.tailwindConfig.prefix === '') {
return matches
@ -90,10 +52,13 @@ function applyPrefix(matches, context) {
return matches
}
function applyImportant(matches, classCandidate) {
function applyImportant(matches, parsed) {
if (matches.length === 0) {
return matches
}
let classCandidate = parsed.withoutVariants
let result = []
for (let [meta, rule] of matches) {
@ -129,159 +94,159 @@ function applyVariant(variant, matches, context) {
let args
// Find partial arbitrary variants
if (variant.endsWith(']') && !variant.startsWith('[')) {
args = variant.slice(variant.lastIndexOf('[') + 1, -1)
variant = variant.slice(0, variant.indexOf(args) - 1 /* - */ - 1 /* [ */)
}
if (variant.type === 'constrained') {
variant = variant.name
} else if (variant.type === 'partial' && variant.value !== '') {
// Find partial arbitrary variants
args = variant.value
variant = variant.name
} else if (variant.type === 'custom' && !context.variantMap.has(variant.raw)) {
// Register arbitrary variants
let fn = parseVariant(variant.value)
// Register arbitrary variants
if (isArbitraryValue(variant) && !context.variantMap.has(variant)) {
let selector = normalize(variant.slice(1, -1))
if (!isValidVariantFormatString(selector)) {
if (!isValidVariantFormatString(variant.value)) {
return []
}
let fn = parseVariant(selector)
let sort = Array.from(context.variantOrder.values()).pop() << 1n
context.variantMap.set(variant, [[sort, fn]])
context.variantOrder.set(variant, sort)
context.variantMap.set(variant.raw, [[sort, fn]])
context.variantOrder.set(variant.raw, sort)
variant = variant.raw
}
if (context.variantMap.has(variant)) {
let variantFunctionTuples = context.variantMap.get(variant)
let result = []
if (!context.variantMap.has(variant)) {
return []
}
for (let [meta, rule] of matches) {
// Don't generate variants for user css
if (meta.layer === 'user') {
let variantFunctionTuples = context.variantMap.get(variant)
let result = []
for (let [meta, rule] of matches) {
// Don't generate variants for user css
if (meta.layer === 'user') {
continue
}
let container = postcss.root({ nodes: [rule.clone()] })
for (let [variantSort, variantFunction] of variantFunctionTuples) {
let clone = container.clone()
let collectedFormats = []
let originals = new Map()
function prepareBackup() {
if (originals.size > 0) return // Already prepared, chicken out
clone.walkRules((rule) => originals.set(rule, rule.selector))
}
function modifySelectors(modifierFunction) {
prepareBackup()
clone.each((rule) => {
if (rule.type !== 'rule') {
return
}
rule.selectors = rule.selectors.map((selector) => {
return modifierFunction({
get className() {
return getClassNameFromSelector(selector)
},
selector,
})
})
})
return clone
}
let ruleWithVariant = variantFunction({
// Public API
get container() {
prepareBackup()
return clone
},
separator: context.tailwindConfig.separator,
modifySelectors,
// Private API for now
wrap(wrapper) {
let nodes = clone.nodes
clone.removeAll()
wrapper.append(nodes)
clone.append(wrapper)
},
format(selectorFormat) {
collectedFormats.push(selectorFormat)
},
args,
})
if (typeof ruleWithVariant === 'string') {
collectedFormats.push(ruleWithVariant)
}
if (ruleWithVariant === null) {
continue
}
let container = postcss.root({ nodes: [rule.clone()] })
// We filled the `originals`, therefore we assume that somebody touched
// `container` or `modifySelectors`. Let's see if they did, so that we
// can restore the selectors, and collect the format strings.
if (originals.size > 0) {
clone.walkRules((rule) => {
if (!originals.has(rule)) return
let before = originals.get(rule)
if (before === rule.selector) return // No mutation happened
for (let [variantSort, variantFunction] of variantFunctionTuples) {
let clone = container.clone()
let collectedFormats = []
let modified = rule.selector
let originals = new Map()
function prepareBackup() {
if (originals.size > 0) return // Already prepared, chicken out
clone.walkRules((rule) => originals.set(rule, rule.selector))
}
function modifySelectors(modifierFunction) {
prepareBackup()
clone.each((rule) => {
if (rule.type !== 'rule') {
return
}
rule.selectors = rule.selectors.map((selector) => {
return modifierFunction({
get className() {
return getClassNameFromSelector(selector)
},
selector,
})
// Rebuild the base selector, this is what plugin authors would do
// as well. E.g.: `${variant}${separator}${className}`.
// However, plugin authors probably also prepend or append certain
// classes, pseudos, ids, ...
let rebuiltBase = selectorParser((selectors) => {
selectors.walkClasses((classNode) => {
classNode.value = `${variant}${context.tailwindConfig.separator}${classNode.value}`
})
})
}).processSync(before)
return clone
}
let ruleWithVariant = variantFunction({
// Public API
get container() {
prepareBackup()
return clone
},
separator: context.tailwindConfig.separator,
modifySelectors,
// Private API for now
wrap(wrapper) {
let nodes = clone.nodes
clone.removeAll()
wrapper.append(nodes)
clone.append(wrapper)
},
format(selectorFormat) {
collectedFormats.push(selectorFormat)
},
args,
// Now that we know the original selector, the new selector, and
// the rebuild part in between, we can replace the part that plugin
// authors need to rebuild with `&`, and eventually store it in the
// collectedFormats. Similar to what `format('...')` would do.
//
// E.g.:
// variant: foo
// selector: .markdown > p
// modified (by plugin): .foo .foo\\:markdown > p
// rebuiltBase (internal): .foo\\:markdown > p
// format: .foo &
collectedFormats.push(modified.replace(rebuiltBase, '&'))
rule.selector = before
})
if (typeof ruleWithVariant === 'string') {
collectedFormats.push(ruleWithVariant)
}
if (ruleWithVariant === null) {
continue
}
// We filled the `originals`, therefore we assume that somebody touched
// `container` or `modifySelectors`. Let's see if they did, so that we
// can restore the selectors, and collect the format strings.
if (originals.size > 0) {
clone.walkRules((rule) => {
if (!originals.has(rule)) return
let before = originals.get(rule)
if (before === rule.selector) return // No mutation happened
let modified = rule.selector
// Rebuild the base selector, this is what plugin authors would do
// as well. E.g.: `${variant}${separator}${className}`.
// However, plugin authors probably also prepend or append certain
// classes, pseudos, ids, ...
let rebuiltBase = selectorParser((selectors) => {
selectors.walkClasses((classNode) => {
classNode.value = `${variant}${context.tailwindConfig.separator}${classNode.value}`
})
}).processSync(before)
// Now that we know the original selector, the new selector, and
// the rebuild part in between, we can replace the part that plugin
// authors need to rebuild with `&`, and eventually store it in the
// collectedFormats. Similar to what `format('...')` would do.
//
// E.g.:
// variant: foo
// selector: .markdown > p
// modified (by plugin): .foo .foo\\:markdown > p
// rebuiltBase (internal): .foo\\:markdown > p
// format: .foo &
collectedFormats.push(modified.replace(rebuiltBase, '&'))
rule.selector = before
})
}
// This tracks the originating layer for the variant
// For example:
// .sm:underline {} is a variant of something in the utilities layer
// .sm:container {} is a variant of the container component
clone.nodes[0].raws.tailwind = { ...clone.nodes[0].raws.tailwind, parentLayer: meta.layer }
let withOffset = [
{
...meta,
sort: variantSort | meta.sort,
collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats),
},
clone.nodes[0],
]
result.push(withOffset)
}
}
return result
// This tracks the originating layer for the variant
// For example:
// .sm:underline {} is a variant of something in the utilities layer
// .sm:container {} is a variant of the container component
clone.nodes[0].raws.tailwind = { ...clone.nodes[0].raws.tailwind, parentLayer: meta.layer }
let withOffset = [
{
...meta,
sort: variantSort | meta.sort,
collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats),
},
clone.nodes[0],
]
result.push(withOffset)
}
}
return []
return result
}
function parseRules(rule, cache, options = {}) {
@ -303,156 +268,76 @@ function parseRules(rule, cache, options = {}) {
return [cache.get(rule), options]
}
const IS_VALID_PROPERTY_NAME = /^[a-z_-]/
function isValidPropName(name) {
return IS_VALID_PROPERTY_NAME.test(name)
}
/**
* @param {string} declaration
* @returns {boolean}
*/
function looksLikeUri(declaration) {
// Quick bailout for obvious non-urls
// This doesn't support schemes that don't use a leading // but that's unlikely to be a problem
if (!declaration.includes('://')) {
return false
}
try {
const url = new URL(declaration)
return url.scheme !== '' && url.host !== ''
} catch (err) {
// Definitely not a valid url
return false
}
}
function isParsableNode(node) {
let isParsable = true
node.walkDecls((decl) => {
if (!isParsableCssValue(decl.name, decl.value)) {
isParsable = false
return false
}
})
return isParsable
}
function isParsableCssValue(property, value) {
// We don't want to to treat [https://example.com] as a custom property
// Even though, according to the CSS grammar, it's a totally valid CSS declaration
// So we short-circuit here by checking if the custom property looks like a url
if (looksLikeUri(`${property}:${value}`)) {
return false
}
try {
postcss.parse(`a{${property}:${value}}`).toResult()
return true
} catch (err) {
return false
}
}
function extractArbitraryProperty(classCandidate, context) {
let [, property, value] = classCandidate.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? []
if (value === undefined) {
return null
}
if (!isValidPropName(property)) {
return null
}
if (!isValidArbitraryValue(value)) {
return null
}
let normalized = normalize(value)
if (!isParsableCssValue(property, normalized)) {
return null
}
function extractArbitraryProperty(parsed, context) {
return [
[
{ sort: context.arbitraryPropertiesSort, layer: 'utilities' },
() => ({
[asClass(classCandidate)]: {
[property]: normalized,
[asClass(parsed.withoutVariants)]: {
[parsed.name]: parsed.value,
},
}),
],
]
}
function* resolveMatchedPlugins(classCandidate, context) {
if (context.candidateRuleMap.has(classCandidate)) {
yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT']
/**
*
* @param {import('./candidate').Candidate} parsed
* @param {any} context
*/
function* resolveMatchedPlugins(parsed, context) {
if (parsed.type === 'custom') {
yield [extractArbitraryProperty(parsed, context), 'DEFAULT']
}
yield* (function* (arbitraryPropertyRule) {
if (arbitraryPropertyRule !== null) {
yield [arbitraryPropertyRule, 'DEFAULT']
}
})(extractArbitraryProperty(classCandidate, context))
let candidatePrefix = classCandidate
let negative = false
const twConfigPrefix = context.tailwindConfig.prefix
const twConfigPrefixLen = twConfigPrefix.length
const hasMatchingPrefix =
candidatePrefix.startsWith(twConfigPrefix) || candidatePrefix.startsWith(`-${twConfigPrefix}`)
if (candidatePrefix[twConfigPrefixLen] === '-' && hasMatchingPrefix) {
negative = true
candidatePrefix = twConfigPrefix + candidatePrefix.slice(twConfigPrefixLen + 1)
if (parsed.type !== 'constrained') {
return
}
if (negative && context.candidateRuleMap.has(candidatePrefix)) {
// TODO: Can this be dropped?
let candidatePrefix = parsed.negative
? parsed.prefix + parsed.withoutVariants.slice(parsed.prefix.length + 1)
: parsed.withoutVariants
if (parsed.negative && context.candidateRuleMap.has(candidatePrefix)) {
yield [context.candidateRuleMap.get(candidatePrefix), '-DEFAULT']
}
for (let [prefix, modifier] of candidatePermutations(candidatePrefix)) {
if (context.candidateRuleMap.has(prefix)) {
yield [context.candidateRuleMap.get(prefix), negative ? `-${modifier}` : modifier]
for (let plugin of parsed.plugins()) {
let ruleGenerator =
context.candidateRuleMap.get(parsed.prefix + plugin.plugin) ||
context.candidateRuleMap.get(plugin.plugin)
if (ruleGenerator) {
yield [ruleGenerator, plugin.value, plugin]
}
}
}
function splitWithSeparator(input, separator) {
if (input === sharedState.NOT_ON_DEMAND) {
return [sharedState.NOT_ON_DEMAND]
}
return Array.from(splitAtTopLevelOnly(input, separator))
}
function* recordCandidates(matches, classCandidate) {
function* recordCandidates(matches, candidate) {
for (const match of matches) {
match[1].raws.tailwind = { ...match[1].raws.tailwind, classCandidate }
match[1].raws.tailwind = {
...match[1].raws.tailwind,
classCandidate: candidate.withoutVariants,
// todo: rename?
parsedCandidate: candidate,
}
yield match
}
}
function* resolveMatches(candidate, context) {
let separator = context.tailwindConfig.separator
let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse()
let important = false
if (classCandidate.startsWith('!')) {
important = true
classCandidate = classCandidate.slice(1)
function* resolveMatches(candidateRaw, context) {
for (const candidate of parseCandidate(candidateRaw, context)) {
yield* resolveMatchesForCandidate(candidate, context)
}
}
function* resolveMatchesForCandidate(candidate, context) {
let important = candidate.important
let variants = candidate.variants
// TODO: Reintroduce this in ways that doesn't break on false positives
// function sortAgainst(toSort, against) {
@ -466,18 +351,20 @@ function* resolveMatches(candidate, context) {
// throw new Error(`Class ${candidate} should be written as ${corrected}`)
// }
for (let matchedPlugins of resolveMatchedPlugins(classCandidate, context)) {
for (let matchedPlugins of resolveMatchedPlugins(candidate, context)) {
let matches = []
let typesByMatches = new Map()
let [plugins, modifier] = matchedPlugins
let [plugins, modifier, candidatePlugin] = matchedPlugins
let isOnlyPlugin = plugins.length === 1
for (let [sort, plugin] of plugins) {
let matchesPerPlugin = []
if (typeof plugin === 'function') {
for (let ruleSet of [].concat(plugin(modifier, { isOnlyPlugin }))) {
for (let ruleSet of [].concat(
plugin(modifier, { isOnlyPlugin, candidate, candidatePlugin })
)) {
let [rules, options] = parseRules(ruleSet, context.postCssNodeCache)
for (let rule of rules) {
matchesPerPlugin.push([{ ...sort, options: { ...sort.options, ...options } }, rule])
@ -499,7 +386,7 @@ function* resolveMatches(candidate, context) {
}
}
if (isArbitraryValue(modifier)) {
if (typeof modifier === 'object') {
// When generated arbitrary values are ambiguous, we can't know
// which to pick so don't generate any utilities for them
if (matches.length > 1) {
@ -542,16 +429,16 @@ function* resolveMatches(candidate, context) {
.join('\n\n')
messages.push(
` Use \`${candidate.replace('[', `[${type}:`)}\` for \`${rules.trim()}\``
` Use \`${candidate.raw.replace('[', `[${type}:`)}\` for \`${rules.trim()}\``
)
break
}
}
log.warn([
`The class \`${candidate}\` is ambiguous and matches multiple utilities.`,
`The class \`${candidate.raw}\` is ambiguous and matches multiple utilities.`,
...messages,
`If this is content and not a class, replace it with \`${candidate
`If this is content and not a class, replace it with \`${candidate.raw
.replace('[', '&lsqb;')
.replace(']', '&rsqb;')}\` to silence this warning.`,
])
@ -562,19 +449,22 @@ function* resolveMatches(candidate, context) {
}
matches = matches.flat()
matches = Array.from(recordCandidates(matches, classCandidate))
matches = Array.from(recordCandidates(matches, candidate))
matches = applyPrefix(matches, context)
if (important) {
matches = applyImportant(matches, classCandidate)
matches = applyImportant(matches, candidate)
}
for (let variant of variants) {
matches = applyVariant(variant, matches, context)
}
let foundMatches = false
for (let match of matches) {
match[1].raws.tailwind = { ...match[1].raws.tailwind, candidate }
foundMatches = true
match[1].raws.tailwind = { ...match[1].raws.tailwind, candidate: candidate.raw }
// Apply final format selector
if (match[0].collectedFormats) {
@ -594,6 +484,25 @@ function* resolveMatches(candidate, context) {
yield match
}
// We always want to emit matches for `DEFAULT`-ish values
// so we'll continue matching in that case
if (
candidatePlugin &&
(candidatePlugin.value === 'DEFAULT' || candidatePlugin.value === '-DEFAULT')
) {
continue
}
// If we've emitted matches we're done searching
// This is because the order of matched values is as follows:
// - Real values from the config
// - Real values from the config accounting for modifiers
// - Arbitrary values
// - Arbitrary values accounting for modifiers
if (foundMatches) {
break
}
}
}
@ -665,8 +574,4 @@ function generateRules(candidates, context) {
})
}
function isArbitraryValue(input) {
return input.startsWith('[') && input.endsWith(']')
}
export { resolveMatches, generateRules }

View file

@ -371,10 +371,17 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
classList.add([prefixedIdentifier, options])
function wrapped(modifier, { isOnlyPlugin }) {
function wrapped(modifier, { isOnlyPlugin, candidate, candidatePlugin }) {
let { type = 'any' } = options
type = [].concat(type)
let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig)
let [value, coercedType] = coerceValue(
type,
modifier,
options,
tailwindConfig,
candidate,
candidatePlugin
)
if (value === undefined) {
return []
@ -384,15 +391,36 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
return []
}
// TODO: Can we remove this?
// This check is done in the candidate section
if (!isValidArbitraryValue(value)) {
return []
}
let tempIdentifier = identifier
if (typeof modifier === 'object') {
modifier = modifier.raw
if (candidate.negative && !tempIdentifier.startsWith('-')) {
tempIdentifier = `-${tempIdentifier}`
}
}
// TODO: Remove and use .className
let alphaModifierValue = candidatePlugin?.modifiers[0]?.value
if (alphaModifierValue !== undefined) {
if (typeof alphaModifierValue === 'object') {
alphaModifierValue = alphaModifierValue.raw
}
modifier += `/${alphaModifierValue}`
}
let ruleSets = []
.concat(rule(value))
.filter(Boolean)
.map((declaration) => ({
[nameClass(identifier, modifier)]: declaration,
[nameClass(tempIdentifier, modifier)]: declaration,
}))
return ruleSets
@ -423,10 +451,17 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
classList.add([prefixedIdentifier, options])
function wrapped(modifier, { isOnlyPlugin }) {
function wrapped(modifier, { isOnlyPlugin, candidate, candidatePlugin }) {
let { type = 'any' } = options
type = [].concat(type)
let [value, coercedType] = coerceValue(type, modifier, options, tailwindConfig)
let [value, coercedType] = coerceValue(
type,
modifier,
options,
tailwindConfig,
candidate,
candidatePlugin
)
if (value === undefined) {
return []
@ -446,15 +481,26 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
}
}
// TODO: Can we remove this?
// This check is done in the candidate section
if (!isValidArbitraryValue(value)) {
return []
}
let tempIdentifier = identifier
if (typeof modifier === 'object') {
modifier = modifier.raw
if (candidate.negative && !tempIdentifier.startsWith('-')) {
tempIdentifier = `-${tempIdentifier}`
}
}
let ruleSets = []
.concat(rule(value))
.filter(Boolean)
.map((declaration) => ({
[nameClass(identifier, modifier)]: declaration,
[nameClass(tempIdentifier, modifier)]: declaration,
}))
return ruleSets

View file

@ -0,0 +1,58 @@
// @ts-check
/** @typedef {import('../lib/candidate.d').Plugin} Plugin */
/**
* Generate plugin permutations for a class candidate, like:
*
* ['ring-offset-blue', '100']
* ['ring-offset', 'blue-100']
* ['ring', 'offset-blue-100']
* Example with dynamic classes:
* ['grid-cols', '[[linename],1fr,auto]']
* ['grid', 'cols-[[linename],1fr,auto]']
*
* @param {string} candidate
* @param {boolean} negative
* @return {Iterable<Plugin>}
*/
export function* candidatePermutations(candidate, negative = false) {
let lastIndex = Infinity
yield { plugin: candidate, value: negative ? '-DEFAULT' : 'DEFAULT', modifiers: [] }
let SPECIALS = /[\[\]]/g
let indexes = Array.from(candidate.matchAll(SPECIALS)).map((r) => r.index)
let firstOpening = indexes[0] ?? -1
let lastOpening = indexes[1] ?? -1
while (lastIndex >= 0) {
let dashIdx
if (lastIndex === Infinity && candidate.endsWith(']')) {
let bracketIdx = candidate.indexOf('[')
// If character before `[` isn't a dash or a slash, this isn't a dynamic class
// eg. string[]
dashIdx = ['-', '/'].includes(candidate[bracketIdx - 1]) ? bracketIdx - 1 : -1
} else {
dashIdx = candidate.lastIndexOf('-', lastIndex)
}
if (dashIdx < 0) {
break
}
if (firstOpening < dashIdx && dashIdx < lastOpening) {
lastIndex = dashIdx - 1
continue
}
let plugin = candidate.slice(0, dashIdx)
let value = candidate.slice(dashIdx + 1)
yield { plugin, value: negative ? `-${value}` : value, modifiers: [] }
lastIndex = dashIdx - 1
}
}

View file

@ -0,0 +1,56 @@
import postcss from 'postcss'
const IS_VALID_PROPERTY_NAME = /^[a-z_-]/
export function isValidPropName(name) {
return IS_VALID_PROPERTY_NAME.test(name)
}
/**
* @param {string} declaration
* @returns {boolean}
*/
export function looksLikeUri(declaration) {
// Quick bailout for obvious non-urls
// This doesn't support schemes that don't use a leading // but that's unlikely to be a problem
if (!declaration.includes('://')) {
return false
}
try {
const url = new URL(declaration)
return url.scheme !== '' && url.host !== ''
} catch (err) {
// Definitely not a valid url
return false
}
}
export function isParsableNode(node) {
let isParsable = true
node.walkDecls((decl) => {
if (!isParsableCssValue(decl.name, decl.value)) {
isParsable = false
return false
}
})
return isParsable
}
export function isParsableCssValue(property, value) {
// We don't want to to treat [https://example.com] as a custom property
// Even though, according to the CSS grammar, it's a totally valid CSS declaration
// So we short-circuit here by checking if the custom property looks like a url
if (looksLikeUri(`${property}:${value}`)) {
return false
}
try {
postcss.parse(`a{${property}:${value}}`).toResult()
return true
} catch (err) {
return false
}
}

View file

@ -6,7 +6,7 @@ let cssFunctions = ['min', 'max', 'clamp', 'calc']
// Ref: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Types
let COMMA = /,(?![^(]*\))/g // Comma separator that is not located between brackets. E.g.: `cubiz-bezier(a, b, c)` these don't count.
let UNDERSCORE = /_(?![^(]*\))/g // Underscore separator that is not located between brackets. E.g.: `rgba(255,_255,_255)_black` these don't count.
let SPACE = /[ ](?![^(]*\))/g // space separator that is not located between brackets. E.g.: `rgba(255, 255, 255) black` these don't count.
// This is not a data type, but rather a function that can normalize the
// correct values.
@ -63,7 +63,7 @@ export function number(value) {
}
export function percentage(value) {
return value.split(UNDERSCORE).every((part) => {
return value.split(SPACE).every((part) => {
return /%$/g.test(part) || cssFunctions.some((fn) => new RegExp(`^${fn}\\(.+?%`).test(part))
})
}
@ -88,7 +88,7 @@ let lengthUnits = [
]
let lengthUnitsPattern = `(?:${lengthUnits.join('|')})`
export function length(value) {
return value.split(UNDERSCORE).every((part) => {
return value.split(SPACE).every((part) => {
return (
part === '0' ||
new RegExp(`${lengthUnitsPattern}$`).test(part) ||
@ -117,7 +117,7 @@ export function shadow(value) {
export function color(value) {
let colors = 0
let result = value.split(UNDERSCORE).every((part) => {
let result = value.split(SPACE).every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true
@ -173,7 +173,7 @@ export function gradient(value) {
let validPositions = new Set(['center', 'top', 'right', 'bottom', 'left'])
export function position(value) {
let positions = 0
let result = value.split(UNDERSCORE).every((part) => {
let result = value.split(SPACE).every((part) => {
part = normalize(part)
if (part.startsWith('var(')) return true

View file

@ -42,13 +42,13 @@ export function finalizeSelector(format, { selector, candidate, context }) {
// ╰──┴─────┴─────────────── We will split here
//
let splitter = new RegExp(`\\${separator}(?![^[]*\\])`)
let base = candidate.split(splitter).pop()
let base = candidate.raw.split(splitter).pop()
if (context?.tailwindConfig?.prefix) {
format = prefixSelector(context.tailwindConfig.prefix, format)
}
format = format.replace(PARENT, `.${escapeClassName(candidate)}`)
format = format.replace(PARENT, `.${escapeClassName(candidate.raw)}`)
let formatAst = selectorParser().astSync(format)

View file

@ -6,6 +6,10 @@ export function asClass(name) {
}
export default function nameClass(classPrefix, key) {
if (typeof key === 'object') {
key = key.raw
}
return asClass(formatClass(classPrefix, key))
}

View file

@ -2,7 +2,6 @@ import selectorParser from 'postcss-selector-parser'
import escapeCommas from './escapeCommas'
import { withAlphaValue } from './withAlphaVariable'
import {
normalize,
length,
number,
percentage,
@ -36,26 +35,18 @@ export function updateAllClasses(selectors, updateClass) {
}
function resolveArbitraryValue(modifier, validate) {
if (!isArbitraryValue(modifier)) {
return undefined
if (isArbitraryValue(modifier)) {
if (!validate(modifier.value)) {
return undefined
}
return modifier.value
}
let value = modifier.slice(1, -1)
if (!validate(value)) {
return undefined
}
return normalize(value)
return undefined
}
function asNegativeValue(modifier, lookup = {}, validate) {
let positiveValue = lookup[modifier]
if (positiveValue !== undefined) {
return negateValue(positiveValue)
}
export function asValue(modifier, options = {}, { candidate, validate = () => true } = {}) {
if (isArbitraryValue(modifier)) {
let resolved = resolveArbitraryValue(modifier, validate)
@ -63,11 +54,13 @@ function asNegativeValue(modifier, lookup = {}, validate) {
return undefined
}
return negateValue(resolved)
}
}
if (options.supportsNegativeValues && candidate.negative) {
return negateValue(resolved)
}
return resolved
}
export function asValue(modifier, options = {}, { validate = () => true } = {}) {
let value = options.values?.[modifier]
if (value !== undefined) {
@ -75,53 +68,68 @@ export function asValue(modifier, options = {}, { validate = () => true } = {})
}
if (options.supportsNegativeValues && modifier.startsWith('-')) {
return asNegativeValue(modifier.slice(1), options.values, validate)
let positiveValue = options.values[modifier.slice(1)]
if (positiveValue !== undefined) {
return negateValue(positiveValue)
}
}
return resolveArbitraryValue(modifier, validate)
return undefined
}
/**
*
* @param {string|import('../lib/candidate').Arbitrary} input
* @returns {input is import('../lib/candidate').Arbitrary}
*/
function isArbitraryValue(input) {
return input.startsWith('[') && input.endsWith(']')
return typeof input === 'object'
}
function splitAlpha(modifier) {
let slashIdx = modifier.lastIndexOf('/')
if (slashIdx === -1 || slashIdx === modifier.length - 1) {
return [modifier]
}
return [modifier.slice(0, slashIdx), modifier.slice(slashIdx + 1)]
}
export function asColor(modifier, options = {}, { tailwindConfig = {} } = {}) {
if (options.values?.[modifier] !== undefined) {
/**
*
* @param {string} modifier
* @param {any} options
* @param {object} param2
* @param {any} param2.tailwindConfig
* @param {import('../lib/candidate').Candidate} param2.candidate
* @param {import('../lib/candidate').Plugin} param2.candidatePlugin
* @returns
*/
export function asColor(
modifier,
options = {},
{ tailwindConfig = {}, candidate, candidatePlugin } = {}
) {
if (options.values?.[modifier] !== undefined && candidatePlugin.modifiers.length === 0) {
return options.values?.[modifier]
}
let [color, alpha] = splitAlpha(modifier)
let alpha = candidatePlugin.modifiers[0]?.value ?? undefined
if (typeof alpha === 'string') {
alpha = tailwindConfig.theme?.opacity?.[alpha] ?? undefined
if (alpha === undefined) {
return undefined
}
} else if (typeof alpha === 'object') {
alpha = alpha.value
}
if (alpha !== undefined) {
let normalizedColor =
options.values?.[color] ?? (isArbitraryValue(color) ? color.slice(1, -1) : undefined)
options.values?.[modifier] ?? (isArbitraryValue(modifier) ? modifier.value : undefined)
if (normalizedColor === undefined) {
return undefined
}
if (isArbitraryValue(alpha)) {
return withAlphaValue(normalizedColor, alpha.slice(1, -1))
}
if (tailwindConfig.theme?.opacity?.[alpha] === undefined) {
return undefined
}
return withAlphaValue(normalizedColor, tailwindConfig.theme.opacity[alpha])
return withAlphaValue(normalizedColor, alpha)
}
return asValue(modifier, options, { validate: validateColor })
return asValue(modifier, options, { candidate, validate: validateColor })
}
export function asLookupValue(modifier, options = {}) {
@ -129,8 +137,8 @@ export function asLookupValue(modifier, options = {}) {
}
function guess(validate) {
return (modifier, options) => {
return asValue(modifier, options, { validate })
return (modifier, options, { candidate }) => {
return asValue(modifier, options, { candidate, validate })
}
}
@ -154,37 +162,22 @@ let typeMap = {
let supportedTypes = Object.keys(typeMap)
function splitAtFirst(input, delim) {
let idx = input.indexOf(delim)
if (idx === -1) return [undefined, input]
return [input.slice(0, idx), input.slice(idx + 1)]
}
export function coerceValue(types, modifier, options, tailwindConfig) {
export function coerceValue(types, modifier, options, tailwindConfig, candidate, candidatePlugin) {
if (isArbitraryValue(modifier)) {
let arbitraryValue = modifier.slice(1, -1)
let [explicitType, value] = splitAtFirst(arbitraryValue, ':')
// It could be that this resolves to `url(https` which is not a valid
// identifier. We currently only support "simple" words with dashes or
// underscores. E.g.: family-name
if (!/^[\w-_]+$/g.test(explicitType)) {
value = arbitraryValue
}
//
else if (explicitType !== undefined && !supportedTypes.includes(explicitType)) {
if (!supportedTypes.includes(modifier.dataType)) {
return []
}
if (value.length > 0 && supportedTypes.includes(explicitType)) {
return [asValue(`[${value}]`, options), explicitType]
// 'any' is the default data type for arbitrary values
// TODO: We should probably guess them when parsing candidates
if (modifier.dataType !== 'any') {
return [asValue(modifier, options), modifier.dataType]
}
}
// Find first matching type
for (let type of [].concat(types)) {
let result = typeMap[type](modifier, options, { tailwindConfig })
let result = typeMap[type](modifier, options, { tailwindConfig, candidate, candidatePlugin })
if (result !== undefined) return [result, type]
}

View file

@ -416,3 +416,78 @@ it('should correctly validate each part when checking for `percentage` data type
`)
})
})
it('arbitrary-value looking things in the config are preferred', () => {
let config = {
content: [{ raw: html`<div class="bg-[#c0ffee]"></div>` }],
corePlugins: { preflight: false, backgroundOpacity: false },
plugins: [],
theme: {
colors: {
'[#c0ffee]': '#f0000d',
},
},
}
let input = css`
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.bg-\[\#c0ffee\] {
background-color: #f0000d;
}
`)
})
})
it('modifier-value looking things in the config are preferred', () => {
let config = {
content: [{ raw: html`<div class="bg-red-500/50"></div>` }],
corePlugins: { preflight: false, backgroundOpacity: false },
plugins: [],
theme: {
colors: {
'red-500/50': '#f0000d',
},
},
}
let input = css`
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.bg-red-500\/50 {
background-color: #f0000d;
}
`)
})
})
it('modifier-value looking things in the config are preferred', () => {
let config = {
content: [{ raw: html`<div class="bg-[#ff0000]/[0.5]"></div>` }],
corePlugins: { preflight: false, backgroundOpacity: false },
plugins: [],
theme: {
colors: {
'[#ff0000]': '#f0000d',
},
},
}
let input = css`
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.bg-\[\#ff0000\]\/\[0\.5\] {
background-color: rgb(240 0 13 / 0.5);
}
`)
})
})

567
tests/candidate.test.js Normal file
View file

@ -0,0 +1,567 @@
const { parseCandidate } = require('../src/lib/candidate.js')
it.each([
[
'border',
[
{
raw: 'border',
className: '.border',
withoutVariants: 'border',
important: false,
negative: false,
prefix: '',
variants: [],
type: 'constrained',
plugins: [{ plugin: 'border', value: 'DEFAULT', modifiers: [] }],
},
],
],
[
'w-4',
[
{
raw: 'w-4',
className: '.w-4',
withoutVariants: 'w-4',
important: false,
negative: false,
prefix: '',
variants: [],
type: 'constrained',
plugins: [
{ plugin: 'w-4', value: 'DEFAULT', modifiers: [] },
{ plugin: 'w', value: '4', modifiers: [] },
],
},
],
],
[
'-w-4',
[
{
raw: '-w-4',
className: '.-w-4',
withoutVariants: '-w-4',
important: false,
negative: true,
prefix: '',
variants: [],
type: 'constrained',
plugins: [
{ plugin: 'w-4', value: '-DEFAULT', modifiers: [] },
{ plugin: 'w', value: '-4', modifiers: [] },
],
},
],
],
[
'sm:w',
[
{
raw: 'sm:w',
className: '.sm\\:w',
withoutVariants: 'w',
important: false,
negative: false,
prefix: '',
variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }],
type: 'constrained',
plugins: [{ plugin: 'w', value: 'DEFAULT', modifiers: [] }],
},
],
],
[
'w-[2px]',
[
{
raw: 'w-[2px]',
className: '.w-\\[2px\\]',
withoutVariants: 'w-[2px]',
important: false,
negative: false,
prefix: '',
variants: [],
type: 'constrained',
plugins: [
{ plugin: 'w-[2px]', value: 'DEFAULT', modifiers: [] },
{ plugin: 'w', value: '[2px]', modifiers: [] },
{
plugin: 'w',
value: {
raw: '[2px]',
value: '2px',
dataType: 'any',
},
modifiers: [],
},
],
},
],
],
[
'-mt-[10px]',
[
{
raw: '-mt-[10px]',
className: '.-mt-\\[10px\\]',
withoutVariants: '-mt-[10px]',
important: false,
negative: true,
prefix: '',
variants: [],
type: 'constrained',
plugins: [
{ plugin: 'mt-[10px]', value: '-DEFAULT', modifiers: [] },
{ plugin: 'mt', value: '-[10px]', modifiers: [] },
{
plugin: 'mt',
value: {
raw: '[10px]',
value: '10px',
dataType: 'any',
},
modifiers: [],
},
],
},
],
],
[
'sm:w-[2px]',
[
{
raw: 'sm:w-[2px]',
className: '.sm\\:w-\\[2px\\]',
withoutVariants: 'w-[2px]',
important: false,
negative: false,
prefix: '',
variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }],
type: 'constrained',
plugins: [
{ plugin: 'w-[2px]', value: 'DEFAULT', modifiers: [] },
{ plugin: 'w', value: '[2px]', modifiers: [] },
{
plugin: 'w',
value: {
raw: '[2px]',
value: '2px',
dataType: 'any',
},
modifiers: [],
},
],
},
],
],
[
'sm:w-[length:2px]',
[
{
raw: 'sm:w-[length:2px]',
className: '.sm\\:w-\\[length\\:2px\\]',
withoutVariants: 'w-[length:2px]',
important: false,
negative: false,
prefix: '',
variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }],
type: 'constrained',
plugins: [
{ plugin: 'w-[length:2px]', value: 'DEFAULT', modifiers: [] },
{ plugin: 'w', value: '[length:2px]', modifiers: [] },
{
plugin: 'w',
value: {
raw: '[length:2px]',
value: '2px',
dataType: 'length',
},
modifiers: [],
},
],
},
],
],
[
'sm:w-[2px]/20',
[
{
raw: 'sm:w-[2px]/20',
className: '.sm\\:w-\\[2px\\]\\/20',
withoutVariants: 'w-[2px]/20',
important: false,
negative: false,
prefix: '',
variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }],
type: 'constrained',
plugins: [
{ plugin: 'w-[2px]/20', value: 'DEFAULT', modifiers: [] },
{ plugin: 'w', value: '[2px]/20', modifiers: [] },
{
plugin: 'w',
value: '[2px]',
modifiers: [{ raw: '20', value: '20' }],
},
{
plugin: 'w',
value: {
raw: `[2px]`,
value: '2px',
dataType: 'any',
},
modifiers: [{ raw: '20', value: '20' }],
},
],
},
],
],
[
'bg-red-500/50',
[
{
raw: 'bg-red-500/50',
className: '.bg-red-500\\/50',
withoutVariants: 'bg-red-500/50',
important: false,
negative: false,
prefix: '',
variants: [],
type: 'constrained',
plugins: [
{ plugin: 'bg-red-500/50', value: 'DEFAULT', modifiers: [] },
{ plugin: 'bg-red', value: '500/50', modifiers: [] },
{ plugin: 'bg-red', value: '500', modifiers: [{ raw: '50', value: '50' }] },
{ plugin: 'bg', value: 'red-500/50', modifiers: [] },
{ plugin: 'bg', value: 'red-500', modifiers: [{ raw: '50', value: '50' }] },
],
},
],
],
[
'bg-[url(https://example.com/image.png)]',
[
{
raw: 'bg-[url(https://example.com/image.png)]',
className: '.bg-\\[url\\(https\\:\\/\\/example\\.com\\/image\\.png\\)\\]',
withoutVariants: 'bg-[url(https://example.com/image.png)]',
important: false,
negative: false,
prefix: '',
variants: [],
type: 'constrained',
plugins: [
{ plugin: 'bg-[url(https://example.com/image.png)]', value: 'DEFAULT', modifiers: [] },
{ plugin: 'bg', value: '[url(https://example.com/image.png)]', modifiers: [] },
{
plugin: 'bg',
value: {
raw: `[url(https://example.com/image.png)]`,
value: 'url(https://example.com/image.png)',
dataType: 'any',
},
modifiers: [],
},
],
},
],
],
[
'bg-[color:]',
[
{
raw: 'bg-[color:]',
className: '.bg-\\[color\\:\\]',
withoutVariants: 'bg-[color:]',
important: false,
negative: false,
prefix: '',
variants: [],
type: 'constrained',
plugins: [
{ plugin: 'bg-[color:]', value: 'DEFAULT', modifiers: [] },
{ plugin: 'bg', value: '[color:]', modifiers: [] },
// [color:] is not a valid arbitrary value
],
},
],
],
[
'-mt-[10px]',
[
{
raw: '-mt-[10px]',
className: '.-mt-\\[10px\\]',
withoutVariants: '-mt-[10px]',
important: false,
negative: true,
prefix: '',
variants: [],
type: 'constrained',
plugins: [
{ plugin: 'mt-[10px]', value: '-DEFAULT', modifiers: [] },
{ plugin: 'mt', value: '-[10px]', modifiers: [] },
{
plugin: 'mt',
value: {
raw: `[10px]`,
value: '10px',
dataType: 'any',
},
modifiers: [],
},
],
},
],
],
[
'sm:w-[2px]/[0.55]',
[
{
raw: 'sm:w-[2px]/[0.55]',
className: '.sm\\:w-\\[2px\\]\\/\\[0\\.55\\]',
withoutVariants: 'w-[2px]/[0.55]',
important: false,
negative: false,
prefix: '',
variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }],
type: 'constrained',
plugins: [
{ plugin: 'w-[2px]/[0.55]', value: 'DEFAULT', modifiers: [] },
{ plugin: 'w', value: '[2px]/[0.55]', modifiers: [] },
{
plugin: 'w',
value: '[2px]',
modifiers: [
{
raw: '[0.55]',
value: {
raw: `[0.55]`,
value: '0.55',
dataType: 'any',
},
},
],
},
{
plugin: 'w',
value: {
raw: `[2px]`,
value: '2px',
dataType: 'any',
},
modifiers: [
{
raw: '[0.55]',
value: {
raw: `[0.55]`,
value: '0.55',
dataType: 'any',
},
},
],
},
],
},
],
],
[
'sm:w-[calc(0.5rem_+_1px)]',
[
{
raw: 'sm:w-[calc(0.5rem_+_1px)]',
className: '.sm\\:w-\\[calc\\(0\\.5rem_\\+_1px\\)\\]',
withoutVariants: 'w-[calc(0.5rem_+_1px)]',
important: false,
negative: false,
prefix: '',
variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }],
type: 'constrained',
plugins: [
{ plugin: 'w-[calc(0.5rem_+_1px)]', value: 'DEFAULT', modifiers: [] },
{ plugin: 'w', value: '[calc(0.5rem_+_1px)]', modifiers: [] },
{
plugin: 'w',
value: {
raw: `[calc(0.5rem_+_1px)]`,
value: 'calc(0.5rem + 1px)',
dataType: 'any',
},
modifiers: [],
},
],
},
],
],
[
'[width:2px]',
[
{
raw: '[width:2px]',
className: '.\\[width\\:2px\\]',
withoutVariants: '[width:2px]',
important: false,
negative: false,
prefix: '',
variants: [],
modifiers: [],
type: 'custom',
name: 'width',
value: '2px',
},
],
],
[
'sm:[width:2px]',
[
{
raw: 'sm:[width:2px]',
className: '.sm\\:\\[width\\:2px\\]',
withoutVariants: '[width:2px]',
important: false,
negative: false,
prefix: '',
variants: [{ raw: 'sm', type: 'constrained', name: 'sm' }],
modifiers: [],
type: 'custom',
name: 'width',
value: '2px',
},
],
],
[
'sm:focus:[width:2px]',
[
{
raw: 'sm:focus:[width:2px]',
className: '.sm\\:focus\\:\\[width\\:2px\\]',
withoutVariants: '[width:2px]',
important: false,
negative: false,
prefix: '',
variants: [
{ raw: 'focus', type: 'constrained', name: 'focus' },
{ raw: 'sm', type: 'constrained', name: 'sm' },
],
modifiers: [],
type: 'custom',
name: 'width',
value: '2px',
},
],
],
[
'sm:[@media(min-width:200px)]:[width:2px]',
[
{
raw: 'sm:[@media(min-width:200px)]:[width:2px]',
className: '.sm\\:\\[\\@media\\(min-width\\:200px\\)\\]\\:\\[width\\:2px\\]',
withoutVariants: '[width:2px]',
important: false,
negative: false,
prefix: '',
variants: [
{
type: 'custom',
raw: '[@media(min-width:200px)]',
value: '@media(min-width:200px)',
dataType: 'any',
},
{ raw: 'sm', type: 'constrained', name: 'sm' },
],
modifiers: [],
type: 'custom',
name: 'width',
value: '2px',
},
],
],
[
'sm:group-[foo_bar]:underline',
[
{
raw: 'sm:group-[foo_bar]:underline',
className: '.sm\\:group-\\[foo_bar\\]\\:underline',
withoutVariants: 'underline',
important: false,
negative: false,
prefix: '',
variants: [
{
type: 'partial',
raw: 'group-[foo_bar]',
name: 'group',
value: 'foo bar',
dataType: 'any',
},
{ raw: 'sm', type: 'constrained', name: 'sm' },
],
type: 'constrained',
plugins: [{ plugin: 'underline', value: 'DEFAULT', modifiers: [] }],
},
],
],
[
'bg-[hsl(var(--foo),var(--bar),var(--baz))]/50',
[
{
raw: 'bg-[hsl(var(--foo),var(--bar),var(--baz))]/50',
className: '.bg-\\[hsl\\(var\\(--foo\\)\\2c var\\(--bar\\)\\2c var\\(--baz\\)\\)\\]\\/50',
withoutVariants: 'bg-[hsl(var(--foo),var(--bar),var(--baz))]/50',
important: false,
negative: false,
prefix: '',
variants: [],
type: 'constrained',
plugins: [
{
plugin: 'bg-[hsl(var(--foo),var(--bar),var(--baz))]/50',
value: 'DEFAULT',
modifiers: [],
},
{
plugin: 'bg',
value: '[hsl(var(--foo),var(--bar),var(--baz))]/50',
modifiers: [],
},
{
plugin: 'bg',
value: '[hsl(var(--foo),var(--bar),var(--baz))]',
modifiers: [{ raw: '50', value: '50' }],
},
{
plugin: 'bg',
value: {
raw: '[hsl(var(--foo),var(--bar),var(--baz))]',
value: 'hsl(var(--foo),var(--bar),var(--baz))',
dataType: 'any',
},
modifiers: [{ raw: '50', value: '50' }],
},
],
},
],
],
// TODO: test for `[color:#ff0000]/50`?
['[http://example.com]', []],
])('should be possible to parse: "%s"', (input, expected) => {
let actual = parseCandidate(input, {
tailwindConfig: {
separator: ':',
},
})
// So we don't mutate the cached versions
actual = actual.map(candidate => ({
...candidate,
}))
for (const candidate of actual) {
if ('plugins' in candidate) {
candidate.plugins = Array.from(candidate.plugins())
}
}
expect(actual).toEqual(expected)
})

View file

@ -1,8 +1,11 @@
import { parseCandidate } from '../src/lib/candidate'
import { formatVariantSelector, finalizeSelector } from '../src/util/formatVariantSelector'
it('should be possible to add a simple variant to a simple selector', () => {
let selector = '.text-center'
let candidate = 'hover:text-center'
let candidate = Array.from(
parseCandidate('hover:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['&:hover']
@ -13,7 +16,9 @@ it('should be possible to add a simple variant to a simple selector', () => {
it('should be possible to add a multiple simple variants to a simple selector', () => {
let selector = '.text-center'
let candidate = 'focus:hover:text-center'
let candidate = Array.from(
parseCandidate('focus:hover:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['&:hover', '&:focus']
@ -24,7 +29,9 @@ it('should be possible to add a multiple simple variants to a simple selector',
it('should be possible to add a simple variant to a selector containing escaped parts', () => {
let selector = '.bg-\\[rgba\\(0\\,0\\,0\\)\\]'
let candidate = 'hover:bg-[rgba(0,0,0)]'
let candidate = Array.from(
parseCandidate('hover:bg-[rgba(0,0,0)]', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['&:hover']
@ -35,7 +42,9 @@ it('should be possible to add a simple variant to a selector containing escaped
it('should be possible to add a simple variant to a selector containing escaped parts (escape is slightly different)', () => {
let selector = '.bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]'
let candidate = 'hover:bg-[rgba(0,0,0)]'
let candidate = Array.from(
parseCandidate('hover:bg-[rgba(0,0,0)]', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['&:hover']
@ -46,7 +55,9 @@ it('should be possible to add a simple variant to a selector containing escaped
it('should be possible to add a simple variant to a more complex selector', () => {
let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])'
let candidate = 'hover:space-x-4'
let candidate = Array.from(
parseCandidate('hover:space-x-4', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['&:hover']
@ -57,7 +68,9 @@ it('should be possible to add a simple variant to a more complex selector', () =
it('should be possible to add multiple simple variants to a more complex selector', () => {
let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])'
let candidate = 'disabled:focus:hover:space-x-4'
let candidate = Array.from(
parseCandidate('disabled:focus:hover:space-x-4', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['&:hover', '&:focus', '&:disabled']
@ -68,7 +81,9 @@ it('should be possible to add multiple simple variants to a more complex selecto
it('should be possible to add a single merge variant to a simple selector', () => {
let selector = '.text-center'
let candidate = 'group-hover:text-center'
let candidate = Array.from(
parseCandidate('group-hover:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [':merge(.group):hover &']
@ -79,7 +94,9 @@ it('should be possible to add a single merge variant to a simple selector', () =
it('should be possible to add multiple merge variants to a simple selector', () => {
let selector = '.text-center'
let candidate = 'group-focus:group-hover:text-center'
let candidate = Array.from(
parseCandidate('group-focus:group-hover:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [':merge(.group):hover &', ':merge(.group):focus &']
@ -90,7 +107,9 @@ it('should be possible to add multiple merge variants to a simple selector', ()
it('should be possible to add a single merge variant to a more complex selector', () => {
let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])'
let candidate = 'group-hover:space-x-4'
let candidate = Array.from(
parseCandidate('group-hover:space-x-4', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [':merge(.group):hover &']
@ -101,7 +120,9 @@ it('should be possible to add a single merge variant to a more complex selector'
it('should be possible to add multiple merge variants to a more complex selector', () => {
let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])'
let candidate = 'group-focus:group-hover:space-x-4'
let candidate = Array.from(
parseCandidate('group-focus:group-hover:space-x-4', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [':merge(.group):hover &', ':merge(.group):focus &']
@ -112,7 +133,9 @@ it('should be possible to add multiple merge variants to a more complex selector
it('should be possible to add multiple unique merge variants to a simple selector', () => {
let selector = '.text-center'
let candidate = 'peer-focus:group-hover:text-center'
let candidate = Array.from(
parseCandidate('peer-focus:group-hover:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [':merge(.group):hover &', ':merge(.peer):focus ~ &']
@ -123,7 +146,9 @@ it('should be possible to add multiple unique merge variants to a simple selecto
it('should be possible to add multiple unique merge variants to a simple selector', () => {
let selector = '.text-center'
let candidate = 'group-hover:peer-focus:text-center'
let candidate = Array.from(
parseCandidate('group-hover:peer-focus:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [':merge(.peer):focus ~ &', ':merge(.group):hover &']
@ -149,7 +174,9 @@ it('should be possible to use multiple :merge() calls with different "arguments"
it('group hover and prose headings combination', () => {
let selector = '.text-center'
let candidate = 'group-hover:prose-headings:text-center'
let candidate = Array.from(
parseCandidate('group-hover:prose-headings:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [
':where(&) :is(h1, h2, h3, h4)', // Prose Headings
':merge(.group):hover &', // Group Hover
@ -162,7 +189,9 @@ it('group hover and prose headings combination', () => {
it('group hover and prose headings combination flipped', () => {
let selector = '.text-center'
let candidate = 'prose-headings:group-hover:text-center'
let candidate = Array.from(
parseCandidate('prose-headings:group-hover:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [
':merge(.group):hover &', // Group Hover
':where(&) :is(h1, h2, h3, h4)', // Prose Headings
@ -175,7 +204,11 @@ it('group hover and prose headings combination flipped', () => {
it('should be possible to handle a complex utility', () => {
let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])'
let candidate = 'peer-disabled:peer-first-child:group-hover:group-focus:focus:hover:space-x-4'
let candidate = Array.from(
parseCandidate('peer-disabled:peer-first-child:group-hover:group-focus:focus:hover:space-x-4', {
tailwindConfig: { separator: ':' },
})
)[0]
let variants = [
'&:hover', // Hover
'&:focus', // Focus
@ -193,7 +226,9 @@ it('should be possible to handle a complex utility', () => {
describe('real examples', () => {
it('example a', () => {
let selector = '.placeholder-red-500::placeholder'
let candidate = 'hover:placeholder-red-500'
let candidate = Array.from(
parseCandidate('hover:placeholder-red-500', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['&:hover']
@ -204,7 +239,9 @@ describe('real examples', () => {
it('example b', () => {
let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])'
let candidate = 'group-hover:hover:space-x-4'
let candidate = Array.from(
parseCandidate('group-hover:hover:space-x-4', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['&:hover', ':merge(.group):hover &']
@ -215,7 +252,9 @@ describe('real examples', () => {
it('should work for group-hover and class dark mode combinations', () => {
let selector = '.text-center'
let candidate = 'dark:group-hover:text-center'
let candidate = Array.from(
parseCandidate('dark:group-hover:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [':merge(.group):hover &', '.dark &']
@ -226,7 +265,9 @@ describe('real examples', () => {
it('should work for group-hover and class dark mode combinations (reversed)', () => {
let selector = '.text-center'
let candidate = 'group-hover:dark:text-center'
let candidate = Array.from(
parseCandidate('group-hover:dark:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['.dark &', ':merge(.group):hover &']
@ -238,7 +279,9 @@ describe('real examples', () => {
describe('prose-headings', () => {
it('should be possible to use hover:prose-headings:text-center', () => {
let selector = '.text-center'
let candidate = 'hover:prose-headings:text-center'
let candidate = Array.from(
parseCandidate('hover:prose-headings:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = [':where(&) :is(h1, h2, h3, h4)', '&:hover']
@ -249,7 +292,9 @@ describe('real examples', () => {
it('should be possible to use prose-headings:hover:text-center', () => {
let selector = '.text-center'
let candidate = 'prose-headings:hover:text-center'
let candidate = Array.from(
parseCandidate('prose-headings:hover:text-center', { tailwindConfig: { separator: ':' } })
)[0]
let variants = ['&:hover', ':where(&) :is(h1, h2, h3, h4)']
@ -276,7 +321,7 @@ describe('pseudo elements', () => {
`('should translate "$before" into "$after"', ({ before, after }) => {
let result = finalizeSelector(formatVariantSelector('&', before), {
selector: '.a',
candidate: 'a',
candidate: Array.from(parseCandidate('a', { tailwindConfig: { separator: ':' } }))[0],
})
expect(result).toEqual(after.replace('&', '.a'))