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 defining simple custom utilities in CSS via @utility #14044

Merged
merged 30 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e402729
Add support for custom static utilities with @utility
adamwathan Jul 19, 2024
71b800b
Add sorting test
thecrypticace Jul 19, 2024
5e19f4d
Add support for custom functional utilities
thecrypticace Jul 19, 2024
63b4baf
Build small AST for custom utility definitions
thecrypticace Jul 19, 2024
800acd4
Allow custom utilities to support arbitrary values
thecrypticace Jul 19, 2024
b22cd97
Allow custom utilities to support negative candidates
thecrypticace Jul 19, 2024
4cd6b74
Rework parsing
thecrypticace Jul 23, 2024
f219ba9
Only allow defining static utilities in CSS
thecrypticace Jul 23, 2024
3a05b4b
Work on addUtilities API
thecrypticace Jul 23, 2024
185e587
Allow static utilities to have `/` in them
thecrypticace Jul 23, 2024
a2ee558
Make regex a bit more strict
thecrypticace Jul 23, 2024
404cd00
Support negative static utilities
thecrypticace Jul 23, 2024
b5dc82b
Drop custom utilities using `-*` in their name
thecrypticace Jul 23, 2024
f1b43d3
Allow re-defining utilities
thecrypticace Jul 23, 2024
88a8c2d
Split utilities into separate lists
thecrypticace Jul 23, 2024
a703b0c
wip
thecrypticace Jul 23, 2024
c93ac30
Make `@utility` names and definition more strict
thecrypticace Jul 23, 2024
2f51d8b
Reorganize tests
thecrypticace Jul 23, 2024
44c39b3
wip
thecrypticace Jul 23, 2024
150ab7f
Update changelog
thecrypticace Jul 23, 2024
3824037
Tweak wording a bit
thecrypticace Jul 23, 2024
ea01b55
Compile utility name regex once
thecrypticace Jul 23, 2024
bb54ecb
Clone nodes before use in custom utilities
thecrypticace Jul 23, 2024
e0baf26
Update packages/tailwindcss/src/index.ts
thecrypticace Jul 23, 2024
83a5a9a
Update packages/tailwindcss/src/index.ts
thecrypticace Jul 23, 2024
211d30f
wip
thecrypticace Jul 23, 2024
63a834a
Fix linting error
thecrypticace Jul 23, 2024
28edd52
Add tests
thecrypticace Jul 24, 2024
02af66d
Remove comment
thecrypticace Jul 24, 2024
4b2ec5d
Optimize implementation of `keys()`
thecrypticace Jul 24, 2024
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 @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add support for basic `addVariant` plugins with new `@plugin` directive ([#13982](https://github.com/tailwindlabs/tailwindcss/pull/13982), [#14008](https://github.com/tailwindlabs/tailwindcss/pull/14008))
- Add `@variant` at-rule for defining custom variants in CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992), [#14008](https://github.com/tailwindlabs/tailwindcss/pull/14008))
- Add `@utility` at-rule for defining custom utilities in CSS ([#14044](https://github.com/tailwindlabs/tailwindcss/pull/14044))

## [4.0.0-alpha.17] - 2024-07-04

Expand Down
81 changes: 32 additions & 49 deletions packages/tailwindcss/src/candidate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,25 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
base = base.slice(1)
}

// Candidates that start with a dash are the negative versions of another
// candidate, e.g. `-mx-4`.
if (base[0] === '-') {
negative = true
base = base.slice(1)
}

// Check for an exact match of a static utility first as long as it does not
// look like an arbitrary value.
if (designSystem.utilities.has(base, 'static') && !base.includes('[')) {
return {
kind: 'static',
root: base,
variants: parsedCandidateVariants,
negative,
important,
}
}

// Figure out the new base and the modifier segment if present.
//
// E.g.:
Expand Down Expand Up @@ -307,13 +326,6 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
}
}

// Candidates that start with a dash are the negative versions of another
// candidate, e.g. `-mx-4`.
if (baseWithoutModifier[0] === '-') {
negative = true
baseWithoutModifier = baseWithoutModifier.slice(1)
}

// The root of the utility, e.g.: `bg-red-500`
// ^^
let root: string | null = null
Expand Down Expand Up @@ -345,28 +357,16 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi

// The root of the utility should exist as-is in the utilities map. If not,
// it's an invalid utility and we can skip continue parsing.
if (!designSystem.utilities.has(root)) return null
if (!designSystem.utilities.has(root, 'functional')) return null

value = baseWithoutModifier.slice(idx + 1)
}

// Not an arbitrary value
else {
;[root, value] = findRoot(baseWithoutModifier, designSystem.utilities)
}

// If the root is null, but it contains a `/`, then it could be that we are
// dealing with a functional utility that contains a modifier but doesn't
// contain a value.
//
// E.g.: `@container/parent`
if (root === null && base.includes('/')) {
let [rootWithoutModifier, rootModifierSegment = null] = segment(base, '/')

modifierSegment = rootModifierSegment

// Try to find the root and value, without the modifier present
;[root, value] = findRoot(rootWithoutModifier, designSystem.utilities)
;[root, value] = findRoot(baseWithoutModifier, (root: string) => {
return designSystem.utilities.has(root, 'functional')
})
}

// If there's no root, the candidate isn't a valid class and can be discarded.
Expand All @@ -377,24 +377,6 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
// can skip any further parsing.
if (value === '') return null

let kind = designSystem.utilities.kind(root)

if (kind === 'static') {
// Static utilities do not have a value
if (value !== null) return null

// Static utilities do not have a modifier
if (modifierSegment !== null) return null

return {
kind: 'static',
root,
variants: parsedCandidateVariants,
negative,
important,
}
}

let candidate: Candidate = {
kind: 'functional',
root,
Expand Down Expand Up @@ -560,7 +542,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
// - `group-hover/foo/bar`
if (additionalModifier) return null

let [root, value] = findRoot(variantWithoutModifier, designSystem.variants)
let [root, value] = findRoot(variantWithoutModifier, (root) => {
return designSystem.variants.has(root)
})

// Variant is invalid, therefore the candidate is invalid and we can skip
// continue parsing it.
Expand Down Expand Up @@ -629,26 +613,25 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia

function findRoot(
input: string,
lookup: { has: (input: string) => boolean },
exists: (input: string) => boolean,
): [string | null, string | null] {
// If the lookup has an exact match, then that's the root.
if (lookup.has(input)) return [input, null]
// If there is an exact match, then that's the root.
if (exists(input)) return [input, null]

// Otherwise test every permutation of the input by iteratively removing
// everything after the last dash.
let idx = input.lastIndexOf('-')
if (idx === -1) {
// Variants starting with `@` are special because they don't need a `-`
// after the `@` (E.g.: `@-lg` should be written as `@lg`).
if (input[0] === '@' && lookup.has('@')) {
if (input[0] === '@' && exists('@')) {
return ['@', input.slice(1)]
}

return [null, null]
}

// Determine the root and value by testing permutations of the incoming input
// against the lookup table.
// Determine the root and value by testing permutations of the incoming input.
//
// In case of a candidate like `bg-red-500`, this looks like:
//
Expand All @@ -658,7 +641,7 @@ function findRoot(
do {
let maybeRoot = input.slice(0, idx)

if (lookup.has(maybeRoot)) {
if (exists(maybeRoot)) {
return [maybeRoot, input.slice(idx + 1)]
}

Expand Down
18 changes: 12 additions & 6 deletions packages/tailwindcss/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,20 @@ export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem

// Handle named utilities
else if (candidate.kind === 'static' || candidate.kind === 'functional') {
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because if the `candidate.root` didn't exist, `parseCandidate`
// would have returned `null` and we would have returned early resulting
// in not hitting this code path.
let { compileFn } = designSystem.utilities.get(candidate.root)!
let fns = designSystem.utilities.get(candidate.root)

// Build the node
let compiledNodes = compileFn(candidate)
let compiledNodes: AstNode[] | undefined

for (let i = fns.length - 1; i >= 0; i--) {
let fn = fns[i]

if (candidate.kind !== fn.kind) continue

compiledNodes = fn.compileFn(candidate)
if (compiledNodes) break
}

if (compiledNodes === undefined) return null

nodes = compiledNodes
Expand Down
35 changes: 35 additions & 0 deletions packages/tailwindcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ import { buildDesignSystem, type DesignSystem } from './design-system'
import { Theme } from './theme'
import { segment } from './utils/segment'

const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/

type PluginAPI = {
addVariant(name: string, variant: string | string[] | CssInJs): void
}

type Plugin = (api: PluginAPI) => void

type CompileOptions = {
Expand Down Expand Up @@ -52,6 +55,7 @@ export function compile(
let theme = new Theme()
let plugins: Plugin[] = []
let customVariants: ((designSystem: DesignSystem) => void)[] = []
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let firstThemeRule: Rule | null = null
let keyframesRules: Rule[] = []

Expand All @@ -65,6 +69,33 @@ export function compile(
return
}

// Collect custom `@utility` at-rules
if (node.selector.startsWith('@utility ')) {
let name = node.selector.slice(9).trim()

if (!IS_VALID_UTILITY_NAME.test(name)) {
throw new Error(
`\`@utility ${name}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`,
)
}

if (node.nodes.length === 0) {
throw new Error(
`\`@utility ${name}\` is empty. Utilities should include at least one property.`,
)
}

customUtilities.push((designSystem) => {
designSystem.utilities.static(name, (candidate) => {
if (candidate.negative) return
return structuredClone(node.nodes)
})
})
philipp-spiess marked this conversation as resolved.
Show resolved Hide resolved

replaceWith([])
return
}

// Register custom variants from `@variant` at-rules
if (node.selector.startsWith('@variant ')) {
if (parent !== null) {
Expand Down Expand Up @@ -224,6 +255,10 @@ export function compile(
customVariant(designSystem)
}

for (let customUtility of customUtilities) {
customUtility(designSystem)
}

let api: PluginAPI = {
addVariant(name, variant) {
// Single selector
Expand Down
17 changes: 6 additions & 11 deletions packages/tailwindcss/src/intellisense.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,13 @@ export type ClassEntry = [string, ClassMetadata]
export function getClassList(design: DesignSystem): ClassEntry[] {
let list: [string, ClassMetadata][] = []

for (let [utility, fn] of design.utilities.entries()) {
if (typeof utility !== 'string') {
continue
}

// Static utilities only work as-is
if (fn.kind === 'static') {
list.push([utility, { modifiers: [] }])
continue
}
// Static utilities only work as-is
for (let utility of design.utilities.keys('static')) {
list.push([utility, { modifiers: [] }])
}

// Functional utilities have their own list of completions
// Functional utilities have their own list of completions
for (let utility of design.utilities.keys('functional')) {
let completions = design.utilities.getCompletions(utility)

for (let group of completions) {
Expand Down
Loading
Loading