From 4b968097afa0c56022ffe95efbfe04efcdb3ebf8 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 19 Dec 2024 12:46:33 +0100 Subject: [PATCH 01/25] add tests --- packages/tailwindcss/src/utilities.test.ts | 285 +++++++++++++++++++++ 1 file changed, 285 insertions(+) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 9cbd0fc04c1c..187fbcae2f03 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17438,4 +17438,289 @@ describe('custom utilities', () => { `[Error: You cannot \`@apply\` the \`hover:bar\` utility here because it creates a circular dependency.]`, ) }) + + describe('functional utilities', () => { + test('resolving values from `@theme`', async () => { + let input = css` + @theme reference { + --tab-size-1: 1; + --tab-size-2: 2; + --tab-size-4: 4; + --tab-size-github: 8; + } + + @utility tab-* { + tab-size: value(--tab-size); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github']), + ).toMatchInlineSnapshot() + expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('') + }) + + test('resolving values from `@theme`, with `--tab-size-*` syntax', async () => { + let input = + // Explicitly not using the css tagged template literal so that + // Prettier doesn't format the `value(--tab-size-*)` as + // `value(--tab-size- *)` + ` + @theme reference { + --tab-size-1: 1; + --tab-size-2: 2; + --tab-size-4: 4; + --tab-size-github: 8; + } + + @utility tab-* { + tab-size: value(--tab-size-*); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github']), + ).toMatchInlineSnapshot() + expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('') + }) + + test('resolving bare values', async () => { + let input = css` + @utility tab-* { + tab-size: value(integer); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['tab-1', 'tab-76', 'tab-971'])).toMatchInlineSnapshot() + expect(await compileCss(input, ['tab-foo'])).toEqual('') + }) + + test('resolving arbitrary values', async () => { + let input = css` + @utility tab-* { + tab-size: value([integer]); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, [ + 'tab-[1]', + 'tab-[76]', + 'tab-[971]', + 'tab-[integer:var(--my-value)]', + 'tab-(integer:my-value)', + ]), + ).toMatchInlineSnapshot() + expect( + await compileCss(input, [ + 'tab-[#0088cc]', + 'tab-[1px]', + 'tab-[var(--my-value)]', + 'tab-(--my-value)', + 'tab-[color:var(--my-value)]', + 'tab-(color:--my-value)', + ]), + ).toEqual('') + }) + + test('resolving any arbitrary values', async () => { + let input = css` + @utility tab-* { + tab-size: value([ *]); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, [ + 'tab-[1]', + 'tab-[76]', + 'tab-[971]', + 'tab-[var(--my-value)]', + 'tab-(--my-value)', + ]), + ).toMatchInlineSnapshot() + expect( + await compileCss(input, [ + 'tab-[#0088cc]', + 'tab-[1px]', + 'tab-[var(--my-value)]', + 'tab-(--my-value)', + ]), + ).toEqual('') + }) + + test('resolving theme, bare and arbitrary values all at once', async () => { + let input = css` + @theme reference { + --tab-size-github: 8; + } + + @utility tab-* { + tab-size: value([integer]); + tab-size: value(integer); + tab-size: value(--tab-size); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['tab-github', 'tab-76', 'tab-[123]'])).toMatchInlineSnapshot() + expect(await compileCss(input, ['tab-[#0088cc]', 'tab-[1px]'])).toEqual('') + }) + + test('in combination with calc to produce different data types of values', async () => { + let input = css` + @theme reference { + --example-full: 100%; + } + + @utility example-* { + --value: value([percentage]); + --value: calc(value(integer) * 1%); + --value: value(--example); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, ['example-full', 'example-12', 'example-[20%]']), + ).toMatchInlineSnapshot() + expect(await compileCss(input, ['example-half', 'example-[#0088cc]'])).toEqual('') + }) + + test('shorthand if resulting values are of the same type', async () => { + let input = css` + @theme reference { + --tab-size-github: 8; + --example-full: 100%; + } + + @utility tab-* { + tab-size: value(--tab-size, integer, [integer]); + } + + @utility example-* { + --value: calc(value(integer) * 1%); + --value: value(--example, [percentage]); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, [ + 'tab-github', + 'tab-76', + 'tab-[123]', + 'example-37', + 'example-[50%]', + 'example-full', + ]), + ).toMatchInlineSnapshot() + expect( + await compileCss(input, ['tab-[#0088cc]', 'tab-[1px]', 'example-foo', 'example-[13px]']), + ).toEqual('') + }) + + test('negative values', async () => { + let input = css` + @theme reference { + --example-full: 100%; + } + + @utility example-* { + --value: value(--example, [percentage], [length]); + } + + @utility -example-* { + --value: calc(value(--example, [percentage], [length]) * -1); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, [ + 'example-full', + '-example-full', + 'example-[10px]', + '-example-[10px]', + 'example-[20%]', + '-example-[20%]', + ]), + ).toMatchInlineSnapshot() + expect(await compileCss(input, ['example-10'])).toEqual('') + }) + + test('using the same value multiple times', async () => { + let input = css` + @utility example-* { + --value: calc(var(--spacing) * value(number)) calc(var(--spacing) * value(number)); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['example-12'])).toMatchInlineSnapshot() + }) + + test('modifiers', async () => { + let input = css` + @theme reference { + --value-sm: 14px; + --modifier-7: 28px; + } + + @utility example-* { + --value: value(--value, [length]); + --modifier: modifier(--modifier, [length]); + --modifier-with-calc: calc(modifier(--modifier, [length]) * 2); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, [ + 'example-sm', + 'example-sm/7', + 'example-[12px]', + 'example-[12px]/[16px]', + ]), + ).toMatchInlineSnapshot() + expect( + await compileCss(input, ['example-foo', 'example-foo/[12px]', 'example-foo/12']), + ).toEqual('') + }) + + test('fractions', async () => { + let input = css` + @theme reference { + --example-video: 16 / 9; + } + + @utility example-* { + --value: value(--example, ratio, [ratio]); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, ['example-video', 'example-1/1', 'example-[7/9]']), + ).toMatchInlineSnapshot() + expect(await compileCss(input, ['example-foo'])).toEqual('') + }) + }) }) From 8336da82ac20f976c653e0715bf29c858d4c846f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 19 Dec 2024 14:05:53 +0100 Subject: [PATCH 02/25] use different character, this is now allowed --- packages/tailwindcss/src/utilities.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 187fbcae2f03..4251b51d57a0 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17259,7 +17259,7 @@ describe('custom utilities', () => { test('custom utilities must use a valid name definitions', async () => { await expect(() => compile(css` - @utility push-* { + @utility push-| { right: 100%; } `), From 1d9c4746a614e699990b69d4a8fe0a62aa2abbcc Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 19 Dec 2024 14:06:09 +0100 Subject: [PATCH 03/25] add `ratio` and `integer` data types --- packages/tailwindcss/src/utils/infer-data-type.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/tailwindcss/src/utils/infer-data-type.ts b/packages/tailwindcss/src/utils/infer-data-type.ts index c6a0881a8228..56ab9c60cdde 100644 --- a/packages/tailwindcss/src/utils/infer-data-type.ts +++ b/packages/tailwindcss/src/utils/infer-data-type.ts @@ -6,7 +6,9 @@ type DataType = | 'color' | 'length' | 'percentage' + | 'ratio' | 'number' + | 'integer' | 'url' | 'position' | 'bg-size' @@ -23,7 +25,9 @@ const checks: Record boolean> = { color: isColor, length: isLength, percentage: isPercentage, + ratio: isFraction, number: isNumber, + integer: isPositiveInteger, url: isUrl, position: isBackgroundPosition, 'bg-size': isBackgroundSize, @@ -173,6 +177,14 @@ function isPercentage(value: string): boolean { /* -------------------------------------------------------------------------- */ +const IS_FRACTION = new RegExp(`^${HAS_NUMBER.source}\s*/\s*${HAS_NUMBER.source}$`) + +function isFraction(value: string): boolean { + return IS_FRACTION.test(value) || hasMathFn(value) +} + +/* -------------------------------------------------------------------------- */ + /** * Please refer to MDN when updating this list: * @see https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Values_and_units From 1013e915b5697fcbb6f300ae895acd9566b298f7 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 19 Dec 2024 14:21:00 +0100 Subject: [PATCH 04/25] implement functional utilities via CSS, e.g.: `@utility tab-*` --- packages/tailwindcss/src/index.ts | 230 +++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 161b1367dcf8..b0354874526b 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -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 } 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/%._-]*-\*$/ type CompileOptions = { base?: string @@ -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.`, ) @@ -188,9 +191,228 @@ async function parseCss( ) } - customUtilities.push((designSystem) => { - designSystem.utilities.static(name, () => structuredClone(node.nodes)) - }) + // Functional utilities. E.g.: `tab-size-*` + 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 + + // 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' && + 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') { + 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) { + 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') { + // 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') { + let value = candidate.modifier.value + let type = inferDataType(value, [arg.value as any]) + if (type !== null) { + 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) + } + }) + + 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 } From 8d5a473d0fbbdb52ec50d109a81874dd33392f42 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 19 Dec 2024 17:11:45 +0100 Subject: [PATCH 05/25] update tests --- packages/tailwindcss/src/utilities.test.ts | 232 ++++++++++++++++++--- 1 file changed, 204 insertions(+), 28 deletions(-) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 4251b51d57a0..3ee582777144 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17456,9 +17456,24 @@ describe('custom utilities', () => { @tailwind utilities; ` - expect( - await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github']), - ).toMatchInlineSnapshot() + expect(await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github'])) + .toMatchInlineSnapshot(` + ".tab-1 { + tab-size: 1; + } + + .tab-2 { + tab-size: 2; + } + + .tab-4 { + tab-size: 4; + } + + .tab-github { + tab-size: 8; + }" + `) expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('') }) @@ -17482,9 +17497,24 @@ describe('custom utilities', () => { @tailwind utilities; ` - expect( - await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github']), - ).toMatchInlineSnapshot() + expect(await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github'])) + .toMatchInlineSnapshot(` + ".tab-1 { + tab-size: 1; + } + + .tab-2 { + tab-size: 2; + } + + .tab-4 { + tab-size: 4; + } + + .tab-github { + tab-size: 8; + }" + `) expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('') }) @@ -17497,7 +17527,19 @@ describe('custom utilities', () => { @tailwind utilities; ` - expect(await compileCss(input, ['tab-1', 'tab-76', 'tab-971'])).toMatchInlineSnapshot() + expect(await compileCss(input, ['tab-1', 'tab-76', 'tab-971'])).toMatchInlineSnapshot(` + ".tab-1 { + tab-size: 1; + } + + .tab-76 { + tab-size: 76; + } + + .tab-971 { + tab-size: 971; + }" + `) expect(await compileCss(input, ['tab-foo'])).toEqual('') }) @@ -17518,7 +17560,23 @@ describe('custom utilities', () => { 'tab-[integer:var(--my-value)]', 'tab-(integer:my-value)', ]), - ).toMatchInlineSnapshot() + ).toMatchInlineSnapshot(` + ".tab-\\[1\\] { + tab-size: 1; + } + + .tab-\\[76\\] { + tab-size: 76; + } + + .tab-\\[971\\] { + tab-size: 971; + } + + .tab-\\[integer\\:var\\(--my-value\\)\\] { + tab-size: var(--my-value); + }" + `) expect( await compileCss(input, [ 'tab-[#0088cc]', @@ -17548,15 +17606,27 @@ describe('custom utilities', () => { 'tab-[var(--my-value)]', 'tab-(--my-value)', ]), - ).toMatchInlineSnapshot() - expect( - await compileCss(input, [ - 'tab-[#0088cc]', - 'tab-[1px]', - 'tab-[var(--my-value)]', - 'tab-(--my-value)', - ]), - ).toEqual('') + ).toMatchInlineSnapshot(` + ".tab-\\(--my-value\\) { + tab-size: var(--my-value); + } + + .tab-\\[1\\] { + tab-size: 1; + } + + .tab-\\[76\\] { + tab-size: 76; + } + + .tab-\\[971\\] { + tab-size: 971; + } + + .tab-\\[var\\(--my-value\\)\\] { + tab-size: var(--my-value); + }" + `) }) test('resolving theme, bare and arbitrary values all at once', async () => { @@ -17574,7 +17644,19 @@ describe('custom utilities', () => { @tailwind utilities; ` - expect(await compileCss(input, ['tab-github', 'tab-76', 'tab-[123]'])).toMatchInlineSnapshot() + expect(await compileCss(input, ['tab-github', 'tab-76', 'tab-[123]'])).toMatchInlineSnapshot(` + ".tab-76 { + tab-size: 76; + } + + .tab-\\[123\\] { + tab-size: 123; + } + + .tab-github { + tab-size: 8; + }" + `) expect(await compileCss(input, ['tab-[#0088cc]', 'tab-[1px]'])).toEqual('') }) @@ -17593,9 +17675,20 @@ describe('custom utilities', () => { @tailwind utilities; ` - expect( - await compileCss(input, ['example-full', 'example-12', 'example-[20%]']), - ).toMatchInlineSnapshot() + expect(await compileCss(input, ['example-full', 'example-12', 'example-[20%]'])) + .toMatchInlineSnapshot(` + ".example-12 { + --value: calc(12 * 1%); + } + + .example-\\[20\\%\\] { + --value: 20%; + } + + .example-full { + --value: 100%; + }" + `) expect(await compileCss(input, ['example-half', 'example-[#0088cc]'])).toEqual('') }) @@ -17627,7 +17720,31 @@ describe('custom utilities', () => { 'example-[50%]', 'example-full', ]), - ).toMatchInlineSnapshot() + ).toMatchInlineSnapshot(` + ".example-37 { + --value: calc(37 * 1%); + } + + .example-\\[50\\%\\] { + --value: 50%; + } + + .example-full { + --value: 100%; + } + + .tab-76 { + tab-size: 76; + } + + .tab-\\[123\\] { + tab-size: 123; + } + + .tab-github { + tab-size: 8; + }" + `) expect( await compileCss(input, ['tab-[#0088cc]', 'tab-[1px]', 'example-foo', 'example-[13px]']), ).toEqual('') @@ -17659,7 +17776,31 @@ describe('custom utilities', () => { 'example-[20%]', '-example-[20%]', ]), - ).toMatchInlineSnapshot() + ).toMatchInlineSnapshot(` + ".-example-\\[10px\\] { + --value: calc(10px * -1); + } + + .-example-\\[20\\%\\] { + --value: calc(20% * -1); + } + + .-example-full { + --value: calc(100% * -1); + } + + .example-\\[10px\\] { + --value: 10px; + } + + .example-\\[20\\%\\] { + --value: 20%; + } + + .example-full { + --value: 100%; + }" + `) expect(await compileCss(input, ['example-10'])).toEqual('') }) @@ -17672,7 +17813,11 @@ describe('custom utilities', () => { @tailwind utilities; ` - expect(await compileCss(input, ['example-12'])).toMatchInlineSnapshot() + expect(await compileCss(input, ['example-12'])).toMatchInlineSnapshot(` + ".example-12 { + --value: calc(var(--spacing) * 12) calc(var(--spacing) * 12); + }" + `) }) test('modifiers', async () => { @@ -17698,7 +17843,27 @@ describe('custom utilities', () => { 'example-[12px]', 'example-[12px]/[16px]', ]), - ).toMatchInlineSnapshot() + ).toMatchInlineSnapshot(` + ".example-\\[12px\\] { + --value: 12px; + } + + .example-\\[12px\\]\\/\\[16px\\] { + --value: 12px; + --modifier: 16px; + --modifier-with-calc: calc(16px * 2); + } + + .example-sm { + --value: 14px; + } + + .example-sm\\/7 { + --value: 14px; + --modifier: 28px; + --modifier-with-calc: calc(28px * 2); + }" + `) expect( await compileCss(input, ['example-foo', 'example-foo/[12px]', 'example-foo/12']), ).toEqual('') @@ -17717,9 +17882,20 @@ describe('custom utilities', () => { @tailwind utilities; ` - expect( - await compileCss(input, ['example-video', 'example-1/1', 'example-[7/9]']), - ).toMatchInlineSnapshot() + expect(await compileCss(input, ['example-video', 'example-1/1', 'example-[7/9]'])) + .toMatchInlineSnapshot(` + ".example-1\\/1 { + --value: 1 / 1; + } + + .example-\\[7\\/9\\] { + --value: 7 / 9; + } + + .example-video { + --value: 16 / 9; + }" + `) expect(await compileCss(input, ['example-foo'])).toEqual('') }) }) From f4e7c97abf2cf5ddbbb6ebc4cbe6fd3163f608fc Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 19 Dec 2024 18:07:07 +0100 Subject: [PATCH 06/25] limit `value({type})` to `number`, `integer`, `ratio` and `percentage` --- packages/tailwindcss/src/index.ts | 22 ++++++++++++++++++++++ packages/tailwindcss/src/utilities.test.ts | 12 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index b0354874526b..e85081bdc1ea 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -258,6 +258,17 @@ async function parseCss( // 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. + 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 @@ -352,6 +363,17 @@ async function parseCss( // 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) { diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 3ee582777144..aa2f62b7275d 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17543,6 +17543,18 @@ describe('custom utilities', () => { expect(await compileCss(input, ['tab-foo'])).toEqual('') }) + test('resolving unsupported bare values', async () => { + let input = css` + @utility tab-* { + tab-size: value(color); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['tab-#0088cc', 'tab-foo'])).toEqual('') + }) + test('resolving arbitrary values', async () => { let input = css` @utility tab-* { From 053b937b60ccb9d4b6df3b1db379460405d6dd4d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 20 Dec 2024 11:56:51 +0100 Subject: [PATCH 07/25] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5330bffd728e..e738d386ed1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `@tailwindcss/browser` package to run Tailwind CSS in the browser ([#15558](https://github.com/tailwindlabs/tailwindcss/pull/15558)) - Add `@reference "…"` API as a replacement for the previous `@import "…" reference` option ([#15565](https://github.com/tailwindlabs/tailwindcss/pull/15565)) +- Add functional utility syntax ([#15455](https://github.com/tailwindlabs/tailwindcss/pull/15455)) ### Fixed From 70fe130dcefffedf5abf44c73fc637d12b7a3380 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 20 Dec 2024 12:17:30 +0100 Subject: [PATCH 08/25] adjust comment with an example --- packages/tailwindcss/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index e85081bdc1ea..fcc675b39fb3 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -259,7 +259,9 @@ async function parseCss( // 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. + // 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' && From 451d894447f26644d0a2579608c94b95a154598e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 20 Dec 2024 13:19:36 +0100 Subject: [PATCH 09/25] add validations - If you are using a bare value or modifier that is a number, then we make sure that it is a valid multiplier of `0.25` - If you are using a bare value or modifier that is a percentage, then we make sure that it is a valid positive integer. - If you are using a fraction, then we make sure that both the numerator and denominator are positive integers. - If the bare value resolves to a non-ratio value, and if a modifier is used, then we need to make sure that the modifier resolves as well. E.g.: `example-1/2.3` this won't resolve to a `ratio` because the denominator is invalid. This will resolve to an `integer` or `number` for the value of `1`, but then we need to make sure that `2.3` is a valid modifier. --- packages/tailwindcss/src/index.ts | 57 +++++++++++++++++++++- packages/tailwindcss/src/utilities.test.ts | 41 ++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index fcc675b39fb3..24ff67a1a659 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -26,7 +26,7 @@ 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 } from './utils/infer-data-type' +import { inferDataType, isPositiveInteger, isValidSpacingMultiplier } from './utils/infer-data-type' import { segment } from './utils/segment' import * as ValueParser from './value-parser' import { compoundsForSelectors } from './variants' @@ -217,6 +217,9 @@ async function parseCss( // 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 @@ -277,6 +280,27 @@ async function parseCss( let type = inferDataType(value, [arg.value as any]) if (type !== null) { + // Ratio must be a valid fraction, e.g.: / + 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 @@ -379,6 +403,20 @@ async function parseCss( 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 @@ -424,6 +462,23 @@ async function parseCss( } }) + // 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 diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index aa2f62b7275d..db26b4f3040f 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17543,6 +17543,47 @@ describe('custom utilities', () => { expect(await compileCss(input, ['tab-foo'])).toEqual('') }) + test('resolving bare values with constraints for integer, percentage, and ratio', async () => { + let input = css` + @utility example-* { + --value-as-number: value(number); + --value-as-percentage: value(percentage); + --value-as-ratio: value(ratio); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['example-1', 'example-0.5', 'example-20%', 'example-2/3'])) + .toMatchInlineSnapshot(` + ".example-0\\.5 { + --value-as-number: .5; + } + + .example-1 { + --value-as-number: 1; + } + + .example-2\\/3 { + --value-as-number: 2; + --value-as-ratio: 2 / 3; + } + + .example-20\\% { + --value-as-percentage: 20%; + }" + `) + expect( + await compileCss(input, [ + 'example-1.23', + 'example-12.34%', + 'example-1.2/3', + 'example-1/2.3', + 'example-1.2/3.4', + ]), + ).toEqual('') + }) + test('resolving unsupported bare values', async () => { let input = css` @utility tab-* { From a73e68d533598491a20bbc5bf28e58f2051d4fe4 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 14:22:48 +0100 Subject: [PATCH 10/25] =?UTF-8?q?use=20`--value(=E2=80=A6)`=20and=20`--mod?= =?UTF-8?q?ifier(=E2=80=A6)`=20syntax?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/index.ts | 40 ++++++++--------- packages/tailwindcss/src/utilities.test.ts | 50 +++++++++++----------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 24ff67a1a659..814949272ca6 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -201,16 +201,16 @@ async function parseCss( // accept just `tab-size`, you'd have to use a static utility. if (candidate.value === null) return - // Whether `value(…)` was used + // Whether `--value(…)` was used let usedValueFn = false - // Whether any of the declarations successfully resolved a `value(…)`. + // 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]); + // 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 @@ -220,10 +220,10 @@ async function parseCss( // The resolved value type, e.g.: `integer` let resolvedValueType = null as string | null - // Whether `modifier(…)` was used + // Whether `--modifier(…)` was used let usedModifierFn = false - // Whether any of the declarations successfully resolved a `modifier(…)` + // Whether any of the declarations successfully resolved a `--modifier(…)` let resolvedModifierFn = false walk(ast, (node, { replaceWith: replaceDeclarationWith }) => { @@ -235,12 +235,12 @@ async function parseCss( ValueParser.walk(valueAst, (valueNode, { replaceWith }) => { if (valueNode.kind !== 'function') return - // Value function, e.g.: `value(integer)` - if (valueNode.value === 'value') { + // 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)` + // Resolving theme value, e.g.: `--value(--color)` if ( candidate.value?.kind === 'named' && arg.kind === 'word' && @@ -259,7 +259,7 @@ async function parseCss( } } - // Bare value, e.g.: `value(integer)` + // 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 @@ -307,7 +307,7 @@ async function parseCss( } } - // Arbitrary value, e.g.: `value([integer])` + // Arbitrary value, e.g.: `--value([integer])` else if ( candidate.value?.kind === 'arbitrary' && arg.kind === 'word' && @@ -316,7 +316,7 @@ async function parseCss( ) { let dataType = arg.value.slice(1, -1) - // Allow any data type, e.g.: `value([*])` + // Allow any data type, e.g.: `--value([*])` if (dataType === '*') { resolvedValueFn = true replaceWith(ValueParser.parse(candidate.value.value)) @@ -328,7 +328,7 @@ async function parseCss( // // ```css // @utility tab-* { - // tab-size: value([integer]); + // tab-size: --value([integer]); // } // ``` // @@ -357,8 +357,8 @@ async function parseCss( return ValueParser.ValueWalkAction.Stop } - // Modifier function, e.g.: `modifier(integer)` - else if (valueNode.value === 'modifier') { + // Modifier function, e.g.: `--modifier(integer)` + else if (valueNode.value === '--modifier') { // If there is no modifier present in the candidate, then // the declaration can be removed. if (candidate.modifier === null) { @@ -369,7 +369,7 @@ async function parseCss( usedModifierFn = true for (let arg of valueNode.nodes) { - // Resolving theme value, e.g.: `modifier(--color)` + // Resolving theme value, e.g.: `--modifier(--color)` if ( candidate.modifier?.kind === 'named' && arg.kind === 'word' && @@ -387,7 +387,7 @@ async function parseCss( } } - // Bare value, e.g.: `modifier(integer)` + // 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. @@ -423,7 +423,7 @@ async function parseCss( } } - // Arbitrary value, e.g.: `modifier([integer])` + // Arbitrary value, e.g.: `--modifier([integer])` else if ( candidate.modifier?.kind === 'arbitrary' && arg.kind === 'word' && @@ -432,7 +432,7 @@ async function parseCss( ) { let dataType = arg.value.slice(1, -1) - // Allow any data type, e.g.: `value([*])` + // Allow any data type, e.g.: `--value([*])` if (dataType === '*') { resolvedModifierFn = true replaceWith(ValueParser.parse(candidate.modifier.value)) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index db26b4f3040f..4408267b512e 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17450,7 +17450,7 @@ describe('custom utilities', () => { } @utility tab-* { - tab-size: value(--tab-size); + tab-size: --value(--tab-size); } @tailwind utilities; @@ -17491,7 +17491,7 @@ describe('custom utilities', () => { } @utility tab-* { - tab-size: value(--tab-size-*); + tab-size: --value(--tab-size-*); } @tailwind utilities; @@ -17521,7 +17521,7 @@ describe('custom utilities', () => { test('resolving bare values', async () => { let input = css` @utility tab-* { - tab-size: value(integer); + tab-size: --value(integer); } @tailwind utilities; @@ -17546,9 +17546,9 @@ describe('custom utilities', () => { test('resolving bare values with constraints for integer, percentage, and ratio', async () => { let input = css` @utility example-* { - --value-as-number: value(number); - --value-as-percentage: value(percentage); - --value-as-ratio: value(ratio); + --value-as-number: --value(number); + --value-as-percentage: --value(percentage); + --value-as-ratio: --value(ratio); } @tailwind utilities; @@ -17587,7 +17587,7 @@ describe('custom utilities', () => { test('resolving unsupported bare values', async () => { let input = css` @utility tab-* { - tab-size: value(color); + tab-size: --value(color); } @tailwind utilities; @@ -17599,7 +17599,7 @@ describe('custom utilities', () => { test('resolving arbitrary values', async () => { let input = css` @utility tab-* { - tab-size: value([integer]); + tab-size: --value([integer]); } @tailwind utilities; @@ -17645,7 +17645,7 @@ describe('custom utilities', () => { test('resolving any arbitrary values', async () => { let input = css` @utility tab-* { - tab-size: value([ *]); + tab-size: --value([ *]); } @tailwind utilities; @@ -17689,9 +17689,9 @@ describe('custom utilities', () => { } @utility tab-* { - tab-size: value([integer]); - tab-size: value(integer); - tab-size: value(--tab-size); + tab-size: --value([integer]); + tab-size: --value(integer); + tab-size: --value(--tab-size); } @tailwind utilities; @@ -17720,9 +17720,9 @@ describe('custom utilities', () => { } @utility example-* { - --value: value([percentage]); - --value: calc(value(integer) * 1%); - --value: value(--example); + --value: --value([percentage]); + --value: calc(--value(integer) * 1%); + --value: --value(--example); } @tailwind utilities; @@ -17753,12 +17753,12 @@ describe('custom utilities', () => { } @utility tab-* { - tab-size: value(--tab-size, integer, [integer]); + tab-size: --value(--tab-size, integer, [integer]); } @utility example-* { - --value: calc(value(integer) * 1%); - --value: value(--example, [percentage]); + --value: calc(--value(integer) * 1%); + --value: --value(--example, [percentage]); } @tailwind utilities; @@ -17810,11 +17810,11 @@ describe('custom utilities', () => { } @utility example-* { - --value: value(--example, [percentage], [length]); + --value: --value(--example, [percentage], [length]); } @utility -example-* { - --value: calc(value(--example, [percentage], [length]) * -1); + --value: calc(--value(--example, [percentage], [length]) * -1); } @tailwind utilities; @@ -17860,7 +17860,7 @@ describe('custom utilities', () => { test('using the same value multiple times', async () => { let input = css` @utility example-* { - --value: calc(var(--spacing) * value(number)) calc(var(--spacing) * value(number)); + --value: calc(var(--spacing) * --value(number)) calc(var(--spacing) * --value(number)); } @tailwind utilities; @@ -17881,9 +17881,9 @@ describe('custom utilities', () => { } @utility example-* { - --value: value(--value, [length]); - --modifier: modifier(--modifier, [length]); - --modifier-with-calc: calc(modifier(--modifier, [length]) * 2); + --value: --value(--value, [length]); + --modifier: --modifier(--modifier, [length]); + --modifier-with-calc: calc(--modifier(--modifier, [length]) * 2); } @tailwind utilities; @@ -17929,7 +17929,7 @@ describe('custom utilities', () => { } @utility example-* { - --value: value(--example, ratio, [ratio]); + --value: --value(--example, ratio, [ratio]); } @tailwind utilities; From f824ee025c9d859d715f629e25b4327de649e0c2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 14:52:31 +0100 Subject: [PATCH 11/25] move `@utility` registration to `./utilities.ts` --- packages/tailwindcss/src/index.ts | 323 +------------------------- packages/tailwindcss/src/utilities.ts | 316 ++++++++++++++++++++++++- 2 files changed, 318 insertions(+), 321 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 814949272ca6..e9455293c524 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -26,15 +26,12 @@ 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 { generateCssUtilities } from './utilities' 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/%._-]*-\*$/ type CompileOptions = { base?: string @@ -177,323 +174,9 @@ async function parseCss( throw new Error('`@utility` cannot be nested.') } - let name = node.params - - 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.`, - ) + for (let utility of generateCssUtilities(node)) { + customUtilities.push(utility) } - - if (node.nodes.length === 0) { - throw new Error( - `\`@utility ${name}\` is empty. Utilities should include at least one property.`, - ) - } - - // Functional utilities. E.g.: `tab-size-*` - 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' && - 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.: / - 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') { - // 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 } // Collect paths from `@source` at-rules diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 21bbda51321b..7fdf6791cd59 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -1,5 +1,6 @@ -import { atRoot, atRule, decl, styleRule, type AstNode } from './ast' +import { atRoot, atRule, decl, styleRule, walk, type AstNode, type AtRule } from './ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate' +import type { DesignSystem } from './design-system' import type { Theme, ThemeKey } from './theme' import { compareBreakpoints } from './utils/compare-breakpoints' import { DefaultMap } from './utils/default-map' @@ -11,6 +12,10 @@ import { } from './utils/infer-data-type' import { replaceShadowColors } from './utils/replace-shadow-colors' import { segment } from './utils/segment' +import * as ValueParser from './value-parser' + +const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/ +const IS_VALID_FUNCTIONAL_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*-\*$/ type CompileFn = ( value: Extract, @@ -4569,3 +4574,312 @@ export function createUtilities(theme: Theme) { return utilities } + +export function* generateCssUtilities(node: AtRule) { + let name = node.params + + 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.`, + ) + } + + if (node.nodes.length === 0) { + throw new Error( + `\`@utility ${name}\` is empty. Utilities should include at least one property.`, + ) + } + + // Functional utilities. E.g.: `tab-size-*` + if (IS_VALID_FUNCTIONAL_UTILITY_NAME.test(name)) { + yield (designSystem: 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' && + 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.: / + 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') { + // 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)) { + yield (designSystem: DesignSystem) => { + designSystem.utilities.static(name, () => structuredClone(node.nodes)) + } + } +} From 9ed85115f6f038ae8dfc1d4e23a2878b025ba5eb Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 16:07:16 +0100 Subject: [PATCH 12/25] move validation up --- packages/tailwindcss/src/index.ts | 17 ++++++++-- packages/tailwindcss/src/utilities.ts | 45 ++++++++++++++++++--------- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index e9455293c524..dd4700f6e569 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -26,7 +26,7 @@ import { substituteFunctions } from './css-functions' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' import { Theme, ThemeOptions } from './theme' -import { generateCssUtilities } from './utilities' +import { createCssUtility } from './utilities' import { segment } from './utils/segment' import { compoundsForSelectors } from './variants' export type Config = UserConfig @@ -174,9 +174,20 @@ async function parseCss( throw new Error('`@utility` cannot be nested.') } - for (let utility of generateCssUtilities(node)) { - customUtilities.push(utility) + if (node.nodes.length === 0) { + throw new Error( + `\`@utility ${node.params}\` is empty. Utilities should include at least one property.`, + ) + } + + let utility = createCssUtility(node) + if (utility === null) { + throw new Error( + `\`@utility ${node.params}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`, + ) } + + customUtilities.push(utility) } // Collect paths from `@source` at-rules diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 7fdf6791cd59..01e24cb79842 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4575,24 +4575,37 @@ export function createUtilities(theme: Theme) { return utilities } -export function* generateCssUtilities(node: AtRule) { +export function createCssUtility(node: AtRule) { let name = node.params - 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.`, - ) - } - - if (node.nodes.length === 0) { - throw new Error( - `\`@utility ${name}\` is empty. Utilities should include at least one property.`, - ) - } - // Functional utilities. E.g.: `tab-size-*` if (IS_VALID_FUNCTIONAL_UTILITY_NAME.test(name)) { - yield (designSystem: DesignSystem) => { + // API: + // + // - `--value(number)` resolves a bare value of type number + // - `--value([number])` resolves an arbitrary value of type number + // - `--value(--color)` resolves a theme value in the `color` namespace + // - `--value(number, [number])` resolves a bare value of type number or an + // arbitrary value of type number in order. + // + // Rules: + // + // - If `--value(…)` does not resolve to a valid value, the declaration is + // removed. + // - If a `--value(ratio)` resolves, the `--modifier(…)` cannot be used. + // - If a candidate looks like `foo-2/3`, then the `--value(ratio)` should + // be used OR the `--value(…)` and `--modifier(:)` must be used. But not + // both. + // - All parts of the candidate must resolve, otherwise it's not a valid + // utility. E.g.:` + // ``` + // @utility foo-* { + // test: value(number) + // } + // ``` + // If you then use `foo-1/2`, this is invalid, because the modifier is not used. + + return (designSystem: DesignSystem) => { designSystem.utilities.functional(name.slice(0, -2), (candidate) => { let ast = structuredClone(node.nodes) @@ -4878,8 +4891,10 @@ export function* generateCssUtilities(node: AtRule) { } if (IS_VALID_UTILITY_NAME.test(name)) { - yield (designSystem: DesignSystem) => { + return (designSystem: DesignSystem) => { designSystem.utilities.static(name, () => structuredClone(node.nodes)) } } + + return null } From 2064b0f23161225bccce4506ca233b395ab97e45 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 16:49:23 +0100 Subject: [PATCH 13/25] improve tests --- packages/tailwindcss/src/utilities.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 4408267b512e..3c9a7fb37591 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17565,7 +17565,6 @@ describe('custom utilities', () => { } .example-2\\/3 { - --value-as-number: 2; --value-as-ratio: 2 / 3; } From faf712f6c22418f731d938b406d225ba8cfe1d95 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 20:47:28 +0100 Subject: [PATCH 14/25] refactor --- packages/tailwindcss/src/utilities.ts | 367 ++++++++++++-------------- 1 file changed, 163 insertions(+), 204 deletions(-) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 01e24cb79842..72284bcb82d8 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -1,4 +1,14 @@ -import { atRoot, atRule, decl, styleRule, walk, type AstNode, type AtRule } from './ast' +import { + atRoot, + atRule, + decl, + styleRule, + walk, + type AstNode, + type AtRule, + type Declaration, + type Rule, +} from './ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate' import type { DesignSystem } from './design-system' import type { Theme, ThemeKey } from './theme' @@ -4607,7 +4617,7 @@ export function createCssUtility(node: AtRule) { return (designSystem: DesignSystem) => { designSystem.utilities.functional(name.slice(0, -2), (candidate) => { - let ast = structuredClone(node.nodes) + let atRule = structuredClone(node) // A value is required for functional utilities, if you want to accept // just `tab-size`, you'd have to use a static utility. @@ -4629,16 +4639,22 @@ export function createCssUtility(node: AtRule) { // 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 }) => { + // A map of all the resolved value data types for a given declaration. + // E.g.: `tab-size: --value(integer)` would resolve to `integer` _if_ it + // properly resolves. + let resolvedDeclarations = new Map() + + // Whether or not `--value(ratio)` was resolved + let resolvedRatioValue = false + + walk([atRule], (node, { parent, replaceWith: replaceDeclarationWith }) => { + if (parent?.kind !== 'rule' && parent?.kind !== 'at-rule') return if (node.kind !== 'declaration') return if (!node.value) return @@ -4651,111 +4667,16 @@ export function createCssUtility(node: AtRule) { if (valueNode.value === '--value') { usedValueFn = true - for (let arg of valueNode.nodes) { - // Resolving theme value, e.g.: `--value(--color)` - if ( - candidate.value?.kind === 'named' && - 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.: / - 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 - } + let resolved = resolveValueFunction(candidate.value!, valueNode, designSystem) + if (resolved) { + resolvedValueFn = true + if (resolved.ratio) { + resolvedRatioValue = true + } else { + resolvedDeclarations.set(node, parent) } + replaceWith(resolved.nodes) + return ValueParser.ValueWalkAction.Skip } // Drop the declaration in case we couldn't resolve the value @@ -4775,83 +4696,11 @@ export function createCssUtility(node: AtRule) { 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 - } - } + let replacement = resolveValueFunction(candidate.modifier!, valueNode, designSystem) + if (replacement) { + resolvedModifierFn = true + replaceWith(replacement.nodes) + return ValueParser.ValueWalkAction.Skip } // Drop the declaration in case we couldn't resolve the value @@ -4866,26 +4715,30 @@ export function createCssUtility(node: AtRule) { } }) - // 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 - } - + // Used `--value(…)` but nothing resolved if (usedValueFn && !resolvedValueFn) return null + + // Used `--modifier(…)` but nothing resolved if (usedModifierFn && !resolvedModifierFn) return null - return ast + // Resolved `--value(ratio)` and `--modifier(…)`, which is invalid + if (resolvedRatioValue && resolvedModifierFn) return null + + // When a candidate has a modifier, then the `--modifier(…)` must + // resolve correctly or the `--value(ratio)` must resolve correctly. + if (candidate.modifier && !resolvedRatioValue && !resolvedModifierFn) return null + + // Resolved `--value(ratio)`, so all other declarations that didn't use + // `--value(ratio)` should be removed. E.g.: `--value(number)` would + // otherwise resolve for `foo-1/2`. + if (resolvedRatioValue) { + for (let [declaration, parent] of resolvedDeclarations) { + let idx = parent.nodes.indexOf(declaration) + if (idx !== -1) parent.nodes.splice(idx, 1) + } + } + + return atRule.nodes }) } } @@ -4898,3 +4751,109 @@ export function createCssUtility(node: AtRule) { return null } + +function resolveValueFunction( + value: NonNullable< + | Extract['value'] + | Extract['modifier'] + >, + fn: ValueParser.ValueFunctionNode, + designSystem: DesignSystem, +): { nodes: ValueParser.ValueAstNode[]; ratio?: boolean } | undefined { + for (let arg of fn.nodes) { + // Resolving theme value, e.g.: `--value(--color)` + if ( + value.kind === 'named' && + arg.kind === 'word' && + arg.value[0] === '-' && + arg.value[1] === '-' + ) { + if (arg.value[arg.value.length - 1] !== '*') arg.value += '-*' + + let resolved = designSystem.resolveThemeValue(arg.value.replace('*', value.value)) + if (resolved) return { nodes: ValueParser.parse(resolved) } + } + + // Bare value, e.g.: `--value(integer)` + else if (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 resolved = arg.value === 'ratio' && 'fraction' in value ? value.fraction : value.value + if (!resolved) continue + + let type = inferDataType(resolved, [arg.value as any]) + if (type === null) continue + + // Ratio must be a valid fraction, e.g.: / + if (type === 'ratio') { + let [lhs, rhs] = segment(resolved, '/') + if (!isPositiveInteger(lhs) || !isPositiveInteger(rhs)) continue + } + + // Non-integer numbers should be a valid multiplier, + // e.g.: `1.5` + else if (type === 'number' && !isValidSpacingMultiplier(resolved)) { + continue + } + + // Percentages must be an integer, e.g.: `50%` + else if (type === 'percentage' && !isPositiveInteger(resolved.slice(0, -1))) { + continue + } + + return { nodes: ValueParser.parse(resolved), ratio: type === 'ratio' } + } + + // Arbitrary value, e.g.: `--value([integer])` + else if ( + 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 === '*') { + return { nodes: ValueParser.parse(value.value) } + } + + // 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 ('dataType' in value && value.dataType && value.dataType !== dataType) { + continue + } + + // Use the provided data type hint + if ('dataType' in value && value.dataType) { + return { nodes: ValueParser.parse(value.value) } + } + + // No data type hitn provided, so we have to infer it + let type = inferDataType(value.value, [dataType as any]) + if (type !== null) { + return { nodes: ValueParser.parse(value.value) } + } + } + } +} From 3b658dfb0644bfff294047ae10ae1b0551a98bfe Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 21:45:26 +0100 Subject: [PATCH 15/25] adjust comments --- packages/tailwindcss/src/utilities.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 72284bcb82d8..7a8d77bfda84 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4645,12 +4645,12 @@ export function createCssUtility(node: AtRule) { // Whether any of the declarations successfully resolved a `--modifier(…)` let resolvedModifierFn = false - // A map of all the resolved value data types for a given declaration. - // E.g.: `tab-size: --value(integer)` would resolve to `integer` _if_ it - // properly resolves. + // A map of all declarations we replaced and their parent rules. We + // might need to remove some later on. E.g.: remove declarations that + // used `--value(number)` when `--value(ratio)` was resolved. let resolvedDeclarations = new Map() - // Whether or not `--value(ratio)` was resolved + // Whether `--value(ratio)` was resolved let resolvedRatioValue = false walk([atRule], (node, { parent, replaceWith: replaceDeclarationWith }) => { From 4d2c36bf2536c81554a5ad1985f29c2119492746 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 22:33:31 +0100 Subject: [PATCH 16/25] ensure `--value(--text-*--line-height)` resolves --- packages/tailwindcss/src/utilities.test.ts | 90 ++++++++++++++++++++++ packages/tailwindcss/src/utilities.ts | 33 +++++++- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 3c9a7fb37591..6fdc20115bee 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17950,5 +17950,95 @@ describe('custom utilities', () => { `) expect(await compileCss(input, ['example-foo'])).toEqual('') }) + + test('resolve theme values with sub-namespace (--text- * --line-height)', async () => { + let input = css` + @theme reference { + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + } + + @utility example-* { + font-size: --value(--text); + line-height: --value(--text- * --line-height); + line-height: --modifier(number); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['example-xs', 'example-xs/6'])).toMatchInlineSnapshot(` + ".example-xs { + font-size: .75rem; + line-height: 1.33333; + } + + .example-xs\\/6 { + font-size: .75rem; + line-height: 6; + }" + `) + expect(await compileCss(input, ['example-foo', 'example-xs/foo'])).toEqual('') + }) + + test('resolve theme values with sub-namespace (--value(--text --line-height))', async () => { + let input = css` + @theme reference { + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + } + + @utility example-* { + font-size: --value(--text); + line-height: --value(--text --line-height); + line-height: --modifier(number); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['example-xs', 'example-xs/6'])).toMatchInlineSnapshot(` + ".example-xs { + font-size: .75rem; + line-height: 1.33333; + } + + .example-xs\\/6 { + font-size: .75rem; + line-height: 6; + }" + `) + expect(await compileCss(input, ['example-foo', 'example-xs/foo'])).toEqual('') + }) + + test('resolve theme values with sub-namespace (--value(--text-*--line-height))', async () => { + let input = ` + @theme reference { + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + } + + @utility example-* { + font-size: --value(--text); + line-height: --value(--text-*--line-height); + line-height: --modifier(number); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['example-xs', 'example-xs/6'])).toMatchInlineSnapshot(` + ".example-xs { + font-size: .75rem; + line-height: 1.33333; + } + + .example-xs\\/6 { + font-size: .75rem; + line-height: 6; + }" + `) + expect(await compileCss(input, ['example-foo', 'example-xs/foo'])).toEqual('') + }) }) }) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 7a8d77bfda84..0b7394fdae53 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4760,6 +4760,37 @@ function resolveValueFunction( fn: ValueParser.ValueFunctionNode, designSystem: DesignSystem, ): { nodes: ValueParser.ValueAstNode[]; ratio?: boolean } | undefined { + // Merge arguments separated by a space, e.g.: `--value(--text-*--line-height)` + // + // Results in: + // [ + // { kind: 'word', value: '--text-*' }, + // { kind: 'separator', value: ' ' }, + // { kind: 'word', value: '--line-height' }, + // ] + // + // But should be: + // [ + // { kind: 'word', value: '--text-*--line-height' }, + // ] + for (let i = fn.nodes.length - 1; i >= 2; --i) { + let lhs = fn.nodes[i - 2] + let sep = fn.nodes[i - 1] + let rhs = fn.nodes[i] + if ( + rhs.kind === 'word' && + sep.kind === 'separator' && + sep.value === ' ' && + lhs.kind === 'word' + ) { + // Ensure `--value(--foo --bar)` results in `--value(--foo-*--bar)` + if (lhs.value[lhs.value.length - 1] !== '*') lhs.value += '-*' + + lhs.value = `${lhs.value}${rhs.value}` + fn.nodes.splice(i - 1, 2) + } + } + for (let arg of fn.nodes) { // Resolving theme value, e.g.: `--value(--color)` if ( @@ -4768,7 +4799,7 @@ function resolveValueFunction( arg.value[0] === '-' && arg.value[1] === '-' ) { - if (arg.value[arg.value.length - 1] !== '*') arg.value += '-*' + if (!arg.value.includes('*')) arg.value += '-*' let resolved = designSystem.resolveThemeValue(arg.value.replace('*', value.value)) if (resolved) return { nodes: ValueParser.parse(resolved) } From e6fd70b4cab3bbb7f7b4040ad2c1a902f839a95b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 22:40:12 +0100 Subject: [PATCH 17/25] allow escaped `\*` for `--value(--tab-size-\*)` This makes it prettier / biome friendly. --- packages/tailwindcss/src/utilities.test.ts | 67 ++++++++++++++++++++++ packages/tailwindcss/src/utilities.ts | 2 +- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 6fdc20115bee..918bc6258d92 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17518,6 +17518,43 @@ describe('custom utilities', () => { expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('') }) + test('resolving values from `@theme`, with `--tab-size-\\*` syntax (prettier friendly)', async () => { + let input = css` + @theme reference { + --tab-size-1: 1; + --tab-size-2: 2; + --tab-size-4: 4; + --tab-size-github: 8; + } + + @utility tab-* { + tab-size: --value(--tab-size-\*); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github'])) + .toMatchInlineSnapshot(` + ".tab-1 { + tab-size: 1; + } + + .tab-2 { + tab-size: 2; + } + + .tab-4 { + tab-size: 4; + } + + .tab-github { + tab-size: 8; + }" + `) + expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('') + }) + test('resolving bare values', async () => { let input = css` @utility tab-* { @@ -17981,6 +18018,36 @@ describe('custom utilities', () => { expect(await compileCss(input, ['example-foo', 'example-xs/foo'])).toEqual('') }) + test('resolve theme values with sub-namespace (--text-\\* --line-height)', async () => { + let input = css` + @theme reference { + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + } + + @utility example-* { + font-size: --value(--text); + line-height: --value(--text-\* --line-height); + line-height: --modifier(number); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['example-xs', 'example-xs/6'])).toMatchInlineSnapshot(` + ".example-xs { + font-size: .75rem; + line-height: 1.33333; + } + + .example-xs\\/6 { + font-size: .75rem; + line-height: 6; + }" + `) + expect(await compileCss(input, ['example-foo', 'example-xs/foo'])).toEqual('') + }) + test('resolve theme values with sub-namespace (--value(--text --line-height))', async () => { let input = css` @theme reference { diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 0b7394fdae53..e6d8110eacab 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4658,7 +4658,7 @@ export function createCssUtility(node: AtRule) { if (node.kind !== 'declaration') return if (!node.value) return - let valueAst = ValueParser.parse(node.value.replace(/\s+\*/g, '*')) + let valueAst = ValueParser.parse(node.value.replace(/(?:\s+|\\)\*/g, '*')) let result = ValueParser.walk(valueAst, (valueNode, { replaceWith }) => { if (valueNode.kind !== 'function') return From e5fd0795b099dfc5e454ec2c34b1170e0d87e6a9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 22:48:45 +0100 Subject: [PATCH 18/25] reduce property access --- packages/tailwindcss/src/utilities.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index e6d8110eacab..0e2b42a898c0 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4619,9 +4619,12 @@ export function createCssUtility(node: AtRule) { designSystem.utilities.functional(name.slice(0, -2), (candidate) => { let atRule = structuredClone(node) + let value = candidate.value + let modifier = candidate.modifier + // 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 + if (value === null) return // Whether `--value(…)` was used let usedValueFn = false @@ -4667,7 +4670,7 @@ export function createCssUtility(node: AtRule) { if (valueNode.value === '--value') { usedValueFn = true - let resolved = resolveValueFunction(candidate.value!, valueNode, designSystem) + let resolved = resolveValueFunction(value, valueNode, designSystem) if (resolved) { resolvedValueFn = true if (resolved.ratio) { @@ -4689,14 +4692,14 @@ export function createCssUtility(node: AtRule) { else if (valueNode.value === '--modifier') { // If there is no modifier present in the candidate, then the // declaration can be removed. - if (candidate.modifier === null) { + if (modifier === null) { replaceDeclarationWith([]) return ValueParser.ValueWalkAction.Skip } usedModifierFn = true - let replacement = resolveValueFunction(candidate.modifier!, valueNode, designSystem) + let replacement = resolveValueFunction(modifier, valueNode, designSystem) if (replacement) { resolvedModifierFn = true replaceWith(replacement.nodes) @@ -4726,7 +4729,7 @@ export function createCssUtility(node: AtRule) { // When a candidate has a modifier, then the `--modifier(…)` must // resolve correctly or the `--value(ratio)` must resolve correctly. - if (candidate.modifier && !resolvedRatioValue && !resolvedModifierFn) return null + if (modifier && !resolvedRatioValue && !resolvedModifierFn) return null // Resolved `--value(ratio)`, so all other declarations that didn't use // `--value(ratio)` should be removed. E.g.: `--value(number)` would From 392dce91e31e3d939c9df2b56ee1ae47bbcc2868 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 7 Jan 2025 22:55:56 +0100 Subject: [PATCH 19/25] adjust comments --- packages/tailwindcss/src/utilities.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 0e2b42a898c0..750c662e89ec 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4604,7 +4604,7 @@ export function createCssUtility(node: AtRule) { // removed. // - If a `--value(ratio)` resolves, the `--modifier(…)` cannot be used. // - If a candidate looks like `foo-2/3`, then the `--value(ratio)` should - // be used OR the `--value(…)` and `--modifier(:)` must be used. But not + // be used OR the `--value(…)` and `--modifier(…)` must be used. But not // both. // - All parts of the candidate must resolve, otherwise it's not a valid // utility. E.g.:` @@ -4883,7 +4883,7 @@ function resolveValueFunction( return { nodes: ValueParser.parse(value.value) } } - // No data type hitn provided, so we have to infer it + // No data type hint provided, so we have to infer it let type = inferDataType(value.value, [dataType as any]) if (type !== null) { return { nodes: ValueParser.parse(value.value) } From 11197f3d5337a8b251880be21e2a3c43822ac450 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 8 Jan 2025 14:21:22 +0100 Subject: [PATCH 20/25] =?UTF-8?q?pre-process=20the=20`--value(=E2=80=A6)`?= =?UTF-8?q?=20and=20`--modifier(=E2=80=A6)`=20AST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/utilities.ts | 88 ++++++++++++++++----------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 750c662e89ec..230cfd28d947 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4616,6 +4616,59 @@ export function createCssUtility(node: AtRule) { // If you then use `foo-1/2`, this is invalid, because the modifier is not used. return (designSystem: DesignSystem) => { + // Pre-process the AST to make it easier to work with. + // + // - Normalize theme values used in `--value(…)` and `--modifier(…)` + // functions. + walk(node.nodes, (child) => { + if (child.kind !== 'declaration') return + if (!child.value) return + if (!child.value.includes('--value(') && !child.value.includes('--modifier(')) return + + let declarationValueAst = ValueParser.parse(child.value) + + // Required manipulations: + // + // - `--value(--spacing)` -> `--value(--spacing-*)` + // - `--value(--spacing- *)` -> `--value(--spacing-*)` + // - `--value(--text- * --line-height)` -> `--value(--text-*--line-height)` + // - `--value(--text --line-height)` -> `--value(--text-*--line-height)` + // - `--value(--text-\\* --line-height)` -> `--value(--text-*--line-height)` + // - `--value([ *])` -> `--value([*])` + // + // Once Prettier / Biome handle these better (e.g.: not crashing without + // `\\*` or not inserting whitespace) then most of these can go away. + ValueParser.walk(declarationValueAst, (fn) => { + if (fn.kind !== 'function') return + if (fn.value !== '--value' && fn.value !== '--modifier') return + + let args = segment(ValueParser.toCss(fn.nodes), ',') + for (let [idx, arg] of args.entries()) { + // Transform escaped `\\*` -> `*` + arg = arg.replace(/\\\*/g, '*') + + // Ensure `--value(--foo --bar)` becomes `--value(--foo-*--bar)` + arg = arg.replace(/--(.*?)\s--(.*?)/g, '--$1-*--$2') + + // Remove whitespace, e.g.: `--value([ *])` -> `--value([*])` + arg = arg.replace(/\s+/g, '') + + // Ensure multiple `-*` becomes a single `-*` + arg = arg.replace(/(-\*){2,}/g, '-*') + + // Ensure trailing `-*` exists if `-*` isn't present yet + if (arg[0] === '-' && arg[1] === '-' && !arg.includes('-*')) { + arg += '-*' + } + + args[idx] = arg + } + fn.nodes = ValueParser.parse(args.join(',')) + }) + + child.value = ValueParser.toCss(declarationValueAst) + }) + designSystem.utilities.functional(name.slice(0, -2), (candidate) => { let atRule = structuredClone(node) @@ -4661,7 +4714,7 @@ export function createCssUtility(node: AtRule) { if (node.kind !== 'declaration') return if (!node.value) return - let valueAst = ValueParser.parse(node.value.replace(/(?:\s+|\\)\*/g, '*')) + let valueAst = ValueParser.parse(node.value) let result = ValueParser.walk(valueAst, (valueNode, { replaceWith }) => { if (valueNode.kind !== 'function') return @@ -4763,37 +4816,6 @@ function resolveValueFunction( fn: ValueParser.ValueFunctionNode, designSystem: DesignSystem, ): { nodes: ValueParser.ValueAstNode[]; ratio?: boolean } | undefined { - // Merge arguments separated by a space, e.g.: `--value(--text-*--line-height)` - // - // Results in: - // [ - // { kind: 'word', value: '--text-*' }, - // { kind: 'separator', value: ' ' }, - // { kind: 'word', value: '--line-height' }, - // ] - // - // But should be: - // [ - // { kind: 'word', value: '--text-*--line-height' }, - // ] - for (let i = fn.nodes.length - 1; i >= 2; --i) { - let lhs = fn.nodes[i - 2] - let sep = fn.nodes[i - 1] - let rhs = fn.nodes[i] - if ( - rhs.kind === 'word' && - sep.kind === 'separator' && - sep.value === ' ' && - lhs.kind === 'word' - ) { - // Ensure `--value(--foo --bar)` results in `--value(--foo-*--bar)` - if (lhs.value[lhs.value.length - 1] !== '*') lhs.value += '-*' - - lhs.value = `${lhs.value}${rhs.value}` - fn.nodes.splice(i - 1, 2) - } - } - for (let arg of fn.nodes) { // Resolving theme value, e.g.: `--value(--color)` if ( @@ -4802,8 +4824,6 @@ function resolveValueFunction( arg.value[0] === '-' && arg.value[1] === '-' ) { - if (!arg.value.includes('*')) arg.value += '-*' - let resolved = designSystem.resolveThemeValue(arg.value.replace('*', value.value)) if (resolved) return { nodes: ValueParser.parse(resolved) } } From 04705aba73a4a4933135a36d5fa33ef99f8d94c4 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 8 Jan 2025 14:22:07 +0100 Subject: [PATCH 21/25] add suggestions for `@utility foo-*` --- packages/tailwindcss/src/intellisense.test.ts | 61 +++++++++++++++++++ packages/tailwindcss/src/theme.ts | 2 +- packages/tailwindcss/src/utilities.ts | 31 ++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/intellisense.test.ts b/packages/tailwindcss/src/intellisense.test.ts index b32883d5cbd1..3d1790f925fa 100644 --- a/packages/tailwindcss/src/intellisense.test.ts +++ b/packages/tailwindcss/src/intellisense.test.ts @@ -437,3 +437,64 @@ test('Custom at-rule variants do not show up as a value under `group`', async () expect(not.values).toContain('variant-3') expect(not.values).toContain('variant-4') }) + +test('Custom functional @utility', async () => { + let input = css` + @import 'tailwindcss/utilities'; + + @theme reference { + --tab-size-1: 1; + --tab-size-2: 2; + --tab-size-4: 4; + --tab-size-github: 8; + + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + + --leading-foo: 1.5; + --leading-bar: 2; + } + + @utility tab-* { + tab-size: --value(--tab-size); + } + + @utility example-* { + font-size: --value(--text); + line-height: --value(--text- * --line-height); + line-height: --modifier(--leading); + } + + @utility -negative-* { + margin: --value(--tab-size- *); + } + ` + + let design = await __unstable__loadDesignSystem(input, { + loadStylesheet: async (_, base) => ({ + base, + content: '@tailwind utilities;', + }), + }) + + let classMap = new Map(design.getClassList()) + let classNames = Array.from(classMap.keys()) + + expect(classNames).toContain('tab-1') + expect(classNames).toContain('tab-2') + expect(classNames).toContain('tab-4') + expect(classNames).toContain('tab-github') + + expect(classNames).not.toContain('-tab-1') + expect(classNames).not.toContain('-tab-2') + expect(classNames).not.toContain('-tab-4') + expect(classNames).not.toContain('-tab-github') + + expect(classNames).toContain('-negative-1') + expect(classNames).toContain('-negative-2') + expect(classNames).toContain('-negative-4') + expect(classNames).toContain('-negative-github') + + expect(classNames).toContain('example-xs') + expect(classMap.get('example-xs')?.modifiers).toEqual(['foo', 'bar']) +}) diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 389b469941f6..03379c7b4e4c 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -68,7 +68,7 @@ export class Theme { } } - keysInNamespaces(themeKeys: ThemeKey[]): string[] { + keysInNamespaces(themeKeys: Iterable): string[] { let keys: string[] = [] for (let namespace of themeKeys) { diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 230cfd28d947..2f679a635e22 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4616,10 +4616,14 @@ export function createCssUtility(node: AtRule) { // If you then use `foo-1/2`, this is invalid, because the modifier is not used. return (designSystem: DesignSystem) => { + let valueThemeKeys = new Set<`--${string}`>() + let modifierThemeKeys = new Set<`--${string}`>() + // Pre-process the AST to make it easier to work with. // // - Normalize theme values used in `--value(…)` and `--modifier(…)` // functions. + // - Track information for suggestions walk(node.nodes, (child) => { if (child.kind !== 'declaration') return if (!child.value) return @@ -4664,6 +4668,19 @@ export function createCssUtility(node: AtRule) { args[idx] = arg } fn.nodes = ValueParser.parse(args.join(',')) + + // Track the theme keys for suggestions + for (let node of fn.nodes) { + if (node.kind === 'word' && node.value[0] === '-' && node.value[1] === '-') { + let value = node.value.replace(/-\*.*$/g, '') as `--${string}` + + if (fn.value === '--value') { + valueThemeKeys.add(value) + } else if (fn.value === '--modifier') { + modifierThemeKeys.add(value) + } + } + } }) child.value = ValueParser.toCss(declarationValueAst) @@ -4796,6 +4813,20 @@ export function createCssUtility(node: AtRule) { return atRule.nodes }) + + designSystem.utilities.suggest(name.slice(0, -2), () => { + return [ + { + supportsNegative: name[0] === '-', + values: designSystem.theme + .keysInNamespaces(valueThemeKeys) + .map((x) => x.replaceAll('_', '.')), + modifiers: designSystem.theme + .keysInNamespaces(modifierThemeKeys) + .map((x) => x.replaceAll('_', '.')), + }, + ] satisfies SuggestionGroup[] + }) } } From a8d5c3d40b90ac9bff6e336314c10d3320798fb3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 8 Jan 2025 14:50:56 +0100 Subject: [PATCH 22/25] utilities are registered as negative already Because of `@utility -foo-*`, this means that we can (and should) drop the `supportsNegative` from the suggestions API, otherwise it would suggestion `--foo-123`. --- packages/tailwindcss/src/intellisense.test.ts | 5 +++++ packages/tailwindcss/src/utilities.ts | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/intellisense.test.ts b/packages/tailwindcss/src/intellisense.test.ts index 3d1790f925fa..abf4dc9bbb6e 100644 --- a/packages/tailwindcss/src/intellisense.test.ts +++ b/packages/tailwindcss/src/intellisense.test.ts @@ -495,6 +495,11 @@ test('Custom functional @utility', async () => { expect(classNames).toContain('-negative-4') expect(classNames).toContain('-negative-github') + expect(classNames).not.toContain('--negative-1') + expect(classNames).not.toContain('--negative-2') + expect(classNames).not.toContain('--negative-4') + expect(classNames).not.toContain('--negative-github') + expect(classNames).toContain('example-xs') expect(classMap.get('example-xs')?.modifiers).toEqual(['foo', 'bar']) }) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 2f679a635e22..14a1549dc529 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4817,7 +4817,6 @@ export function createCssUtility(node: AtRule) { designSystem.utilities.suggest(name.slice(0, -2), () => { return [ { - supportsNegative: name[0] === '-', values: designSystem.theme .keysInNamespaces(valueThemeKeys) .map((x) => x.replaceAll('_', '.')), From 268ad173e71dfd02bd0c3c3d7ad898dd077393c6 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 8 Jan 2025 14:59:29 +0100 Subject: [PATCH 23/25] fix typo --- packages/tailwindcss/src/utilities.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 14a1549dc529..7178db0f0b27 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -4610,7 +4610,7 @@ export function createCssUtility(node: AtRule) { // utility. E.g.:` // ``` // @utility foo-* { - // test: value(number) + // test: --value(number) // } // ``` // If you then use `foo-1/2`, this is invalid, because the modifier is not used. From 2a5a2a060880e0f267f18284b10fa16df6890779 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 8 Jan 2025 15:00:17 +0100 Subject: [PATCH 24/25] add a few more explicit tests --- packages/tailwindcss/src/utilities.test.ts | 80 ++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 918bc6258d92..c1b3dfe6781b 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17718,6 +17718,86 @@ describe('custom utilities', () => { `) }) + test('resolving any arbitrary values (without space)', async () => { + let input = ` + @utility tab-* { + tab-size: --value([*]); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, [ + 'tab-[1]', + 'tab-[76]', + 'tab-[971]', + 'tab-[var(--my-value)]', + 'tab-(--my-value)', + ]), + ).toMatchInlineSnapshot(` + ".tab-\\(--my-value\\) { + tab-size: var(--my-value); + } + + .tab-\\[1\\] { + tab-size: 1; + } + + .tab-\\[76\\] { + tab-size: 76; + } + + .tab-\\[971\\] { + tab-size: 971; + } + + .tab-\\[var\\(--my-value\\)\\] { + tab-size: var(--my-value); + }" + `) + }) + + test('resolving any arbitrary values (with escaped `*`)', async () => { + let input = css` + @utility tab-* { + tab-size: --value([\*]); + } + + @tailwind utilities; + ` + + expect( + await compileCss(input, [ + 'tab-[1]', + 'tab-[76]', + 'tab-[971]', + 'tab-[var(--my-value)]', + 'tab-(--my-value)', + ]), + ).toMatchInlineSnapshot(` + ".tab-\\(--my-value\\) { + tab-size: var(--my-value); + } + + .tab-\\[1\\] { + tab-size: 1; + } + + .tab-\\[76\\] { + tab-size: 76; + } + + .tab-\\[971\\] { + tab-size: 971; + } + + .tab-\\[var\\(--my-value\\)\\] { + tab-size: var(--my-value); + }" + `) + }) + test('resolving theme, bare and arbitrary values all at once', async () => { let input = css` @theme reference { From 62974fe904ae0ff37df64f15697bd06ab446c3d9 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 8 Jan 2025 15:58:49 +0100 Subject: [PATCH 25/25] rename regex --- packages/tailwindcss/src/utilities.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 7178db0f0b27..a3e68e1cbf13 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -24,7 +24,7 @@ import { replaceShadowColors } from './utils/replace-shadow-colors' import { segment } from './utils/segment' import * as ValueParser from './value-parser' -const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/ +const IS_VALID_STATIC_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/ const IS_VALID_FUNCTIONAL_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*-\*$/ type CompileFn = ( @@ -4829,7 +4829,7 @@ export function createCssUtility(node: AtRule) { } } - if (IS_VALID_UTILITY_NAME.test(name)) { + if (IS_VALID_STATIC_UTILITY_NAME.test(name)) { return (designSystem: DesignSystem) => { designSystem.utilities.static(name, () => structuredClone(node.nodes)) }