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 functional utility syntax #15455

Open
wants to merge 9 commits into
base: next
Choose a base branch
from
Open
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add functional utility syntax ([#15455](https://github.com/tailwindlabs/tailwindcss/pull/15455))

### Fixed

- Use the correct property value for `place-content-between`, `place-content-around`, and `place-content-evenly` utilities ([#15440](https://github.com/tailwindlabs/tailwindcss/pull/15440))
Expand Down
309 changes: 305 additions & 4 deletions packages/tailwindcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,15 @@ import { substituteFunctions } from './css-functions'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { Theme, ThemeOptions } from './theme'
import { inferDataType, isPositiveInteger, isValidSpacingMultiplier } from './utils/infer-data-type'
import { segment } from './utils/segment'
import * as ValueParser from './value-parser'
import { compoundsForSelectors } from './variants'
export type Config = UserConfig

const IS_VALID_PREFIX = /^[a-z]+$/
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
const IS_VALID_FUNCTIONAL_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*-\*$/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oxide currently ignores capitals in any class name. Both of these regexes should probably account for that? Or we should relax that restriction in Oxide (but iirc it resulted in a pretty decent reduction in potential candidates).


type CompileOptions = {
base?: string
Expand Down Expand Up @@ -176,7 +179,7 @@ async function parseCss(

let name = node.params

if (!IS_VALID_UTILITY_NAME.test(name)) {
if (!IS_VALID_UTILITY_NAME.test(name) && !IS_VALID_FUNCTIONAL_UTILITY_NAME.test(name)) {
throw new Error(
`\`@utility ${name}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`,
)
Expand All @@ -188,9 +191,307 @@ async function parseCss(
)
}

customUtilities.push((designSystem) => {
designSystem.utilities.static(name, () => structuredClone(node.nodes))
})
// Functional utilities. E.g.: `tab-size-*`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's a good time to pull this stuff out into a separate file? This is a fairly large chunk of code.

if (IS_VALID_FUNCTIONAL_UTILITY_NAME.test(name)) {
customUtilities.push((designSystem) => {
designSystem.utilities.functional(name.slice(0, -2), (candidate) => {
let ast = structuredClone(node.nodes)

// A value is required for functional utilities, if you want to
// accept just `tab-size`, you'd have to use a static utility.
if (candidate.value === null) return

// Whether `value(…)` was used
let usedValueFn = false

// Whether any of the declarations successfully resolved a `value(…)`.
// E.g:
// ```css
// @utility tab-size-* {
// tab-size: value(integer);
// tab-size: value(--tab-size);
// tab-size: value([integer]);
// }
// ```
// Any of these `tab-size` declarations have to resolve to a valid
// in order to make the utility valid.
let resolvedValueFn = false

// The resolved value type, e.g.: `integer`
let resolvedValueType = null as string | null

// Whether `modifier(…)` was used
let usedModifierFn = false

// Whether any of the declarations successfully resolved a `modifier(…)`
let resolvedModifierFn = false

walk(ast, (node, { replaceWith: replaceDeclarationWith }) => {
if (node.kind !== 'declaration') return
if (!node.value) return

let valueAst = ValueParser.parse(node.value.replace(/\s+\*/g, '*'))
let result =
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
if (valueNode.kind !== 'function') return

// Value function, e.g.: `value(integer)`
if (valueNode.value === 'value') {
usedValueFn = true

for (let arg of valueNode.nodes) {
// Resolving theme value, e.g.: `value(--color)`
if (
candidate.value?.kind === 'named' &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We know that candidate.value is not null because of the check on top. Maybe we can store value in a separate value at that point so we can avoid the repeated property access and nullability checks in here?

arg.kind === 'word' &&
arg.value[0] === '-' &&
arg.value[1] === '-'
) {
if (arg.value[arg.value.length - 1] !== '*') arg.value += '-*'

let value = designSystem.resolveThemeValue(
arg.value.replace('*', candidate.value.value),
)
if (value !== undefined) {
resolvedValueFn = true
replaceWith(ValueParser.parse(value))
return ValueParser.ValueWalkAction.Skip
}
}

// Bare value, e.g.: `value(integer)`
else if (candidate.value?.kind === 'named' && arg.kind === 'word') {
// Limit the bare value types, to prevent new syntax
// that we don't want to support. E.g.: `text-#000` is
// something we don't want to support, but could be
// built this way.
if (
arg.value !== 'number' &&
arg.value !== 'integer' &&
arg.value !== 'ratio' &&
arg.value !== 'percentage'
) {
continue
}

let value =
arg.value === 'ratio' ? candidate.value.fraction : candidate.value.value
if (!value) continue

let type = inferDataType(value, [arg.value as any])
if (type !== null) {
// Ratio must be a valid fraction, e.g.: <integer>/<integer>
if (type === 'ratio') {
let [lhs, rhs] = segment(value, '/')
if (!isPositiveInteger(lhs) || !isPositiveInteger(rhs)) continue
}

// Non-integer numbers should be a valid multiplier,
// e.g.: `1.5`
else if (type === 'number' && !isValidSpacingMultiplier(value)) {
continue
}

// Percentages must be an integer, e.g.: `50%`
else if (
type === 'percentage' &&
!isPositiveInteger(value.slice(0, -1))
) {
continue
}

resolvedValueType = type
resolvedValueFn = true
replaceWith(ValueParser.parse(value))
return ValueParser.ValueWalkAction.Skip
}
}

// Arbitrary value, e.g.: `value([integer])`
else if (
candidate.value?.kind === 'arbitrary' &&
arg.kind === 'word' &&
arg.value[0] === '[' &&
arg.value[arg.value.length - 1] === ']'
) {
let dataType = arg.value.slice(1, -1)

// Allow any data type, e.g.: `value([*])`
if (dataType === '*') {
resolvedValueFn = true
replaceWith(ValueParser.parse(candidate.value.value))
return ValueParser.ValueWalkAction.Skip
}

// The forced arbitrary value hint must match the
// expected data type.
//
// ```css
// @utility tab-* {
// tab-size: value([integer]);
// }
// ```
//
// Given a candidate like `tab-(color:var(--my-value))`,
// should not match because `color` and `integer` don't
// match.
if (candidate.value.dataType && candidate.value.dataType !== dataType) {
continue
}

let value = candidate.value.value
let type =
candidate.value.dataType ?? inferDataType(value, [dataType as any])

if (type !== null) {
resolvedValueFn = true
replaceWith(ValueParser.parse(value))
return ValueParser.ValueWalkAction.Skip
}
}
}

// Drop the declaration in case we couldn't resolve the value
usedValueFn ||= false
replaceDeclarationWith([])
return ValueParser.ValueWalkAction.Stop
}

// Modifier function, e.g.: `modifier(integer)`
else if (valueNode.value === 'modifier') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's quite some repetition between the modifier and the value function, maybe we can avoid some code duplication here by adding a more generic resolver function?

// If there is no modifier present in the candidate, then
// the declaration can be removed.
if (candidate.modifier === null) {
replaceDeclarationWith([])
return ValueParser.ValueWalkAction.Skip
}

usedModifierFn = true

for (let arg of valueNode.nodes) {
// Resolving theme value, e.g.: `modifier(--color)`
if (
candidate.modifier?.kind === 'named' &&
arg.kind === 'word' &&
arg.value[0] === '-' &&
arg.value[1] === '-'
) {
if (arg.value[arg.value.length - 1] !== '*') arg.value += '-*'
let themeKey = arg.value.replace('*', candidate.modifier.value)

let value = designSystem.resolveThemeValue(themeKey)
if (value !== undefined) {
resolvedModifierFn = true
replaceWith(ValueParser.parse(value))
return ValueParser.ValueWalkAction.Skip
}
}

// Bare value, e.g.: `modifier(integer)`
else if (candidate.modifier?.kind === 'named' && arg.kind === 'word') {
// Limit the bare value types, to prevent new syntax
// that we don't want to support.
if (
arg.value !== 'number' &&
arg.value !== 'integer' &&
arg.value !== 'ratio' &&
arg.value !== 'percentage'
) {
continue
}

let value = candidate.modifier.value
let type = inferDataType(value, [arg.value as any])
if (type !== null) {
// Non-integer numbers should be a valid multiplier,
// e.g.: `1.5`
if (type === 'number' && !isValidSpacingMultiplier(value)) {
continue
}

// Percentages must be an integer, e.g.: `50%`
else if (
type === 'percentage' &&
!isPositiveInteger(value.slice(0, -1))
) {
continue
}

resolvedModifierFn = true
replaceWith(ValueParser.parse(value))
return ValueParser.ValueWalkAction.Skip
}
}

// Arbitrary value, e.g.: `modifier([integer])`
else if (
candidate.modifier?.kind === 'arbitrary' &&
arg.kind === 'word' &&
arg.value[0] === '[' &&
arg.value[arg.value.length - 1] === ']'
) {
let dataType = arg.value.slice(1, -1)

// Allow any data type, e.g.: `value([*])`
if (dataType === '*') {
resolvedModifierFn = true
replaceWith(ValueParser.parse(candidate.modifier.value))
return ValueParser.ValueWalkAction.Skip
}

let value = candidate.modifier.value
let type = inferDataType(value, [dataType as any])

if (type !== null) {
resolvedModifierFn = true
replaceWith(ValueParser.parse(value))
return ValueParser.ValueWalkAction.Skip
}
}
}

// Drop the declaration in case we couldn't resolve the value
usedModifierFn ||= false
replaceDeclarationWith([])
return ValueParser.ValueWalkAction.Stop
}
}) ?? ValueParser.ValueWalkAction.Continue

if (result === ValueParser.ValueWalkAction.Continue) {
node.value = ValueParser.toCss(valueAst)
}
})

// Ensure that the modifier was resolved if present on the
// candidate. We also have to make sure that the value is _not_
// using a fraction.
//
// E.g.:
//
// - `w-1/2`, can be a value of `1` and modifier of `2`
// - `w-1/2`, can be a fraction of `1/2` and no modifier
if (
candidate.value.kind === 'named' &&
resolvedValueType !== 'ratio' &&
!usedModifierFn &&
candidate.modifier !== null
) {
return null
}

if (usedValueFn && !resolvedValueFn) return null
if (usedModifierFn && !resolvedModifierFn) return null

return ast
})
})
}

if (IS_VALID_UTILITY_NAME.test(name)) {
customUtilities.push((designSystem) => {
designSystem.utilities.static(name, () => structuredClone(node.nodes))
})
}

return
}
Expand Down
Loading