From 6ca8cc6f02466366063e7791a77d53760ddb9cdc Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Wed, 18 Sep 2024 16:49:27 +0200 Subject: [PATCH] Disallow negative bare values (#14453) Right now, it is possible to type `grid-cols--8` which maps to: ```css /* Specificity: (0, 1, 0) */ .grid-cols--8 { grid-template-columns: repeat(-8, minmax(0, 1fr)); } ``` This doesn't make sense so we used this opportunity to audit all variants and utilities and properly disallow negative bare values. Utilities where negative values are supported still work by using the negative utility syntax, e.g.: `-inset-4`. --- CHANGELOG.md | 1 + .../tailwindcss/src/compat/default-theme.ts | 15 +- packages/tailwindcss/src/utilities.test.ts | 172 +++++++++++++++++- packages/tailwindcss/src/utilities.ts | 116 ++++++------ .../tailwindcss/src/utils/infer-data-type.ts | 8 + packages/tailwindcss/src/variants.test.ts | 4 + packages/tailwindcss/src/variants.ts | 9 +- 7 files changed, 252 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 053842c81..e33cbe3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Don't override explicit `leading-*`, `tracking-*`, or `font-{weight}` utilities with font-size utility defaults ([#14403](https://github.com/tailwindlabs/tailwindcss/pull/14403)) +- Disallow negative bare values in core utilities and variants ([#14453](https://github.com/tailwindlabs/tailwindcss/pull/14453)) ## [4.0.0-alpha.24] - 2024-09-11 diff --git a/packages/tailwindcss/src/compat/default-theme.ts b/packages/tailwindcss/src/compat/default-theme.ts index 5b5c3d2ab..857974214 100644 --- a/packages/tailwindcss/src/compat/default-theme.ts +++ b/packages/tailwindcss/src/compat/default-theme.ts @@ -1,4 +1,5 @@ import type { NamedUtilityValue } from '../candidate' +import { isPositiveInteger } from '../utils/infer-data-type' import { segment } from '../utils/segment' import colors from './colors' import type { UserConfig } from './config/types' @@ -13,31 +14,31 @@ function bareValues(fn: (value: NamedUtilityValue) => string | undefined) { } let bareIntegers = bareValues((value) => { - if (!Number.isNaN(Number(value.value))) { + if (isPositiveInteger(value.value)) { return value.value } }) let barePercentages = bareValues((value: NamedUtilityValue) => { - if (!Number.isNaN(Number(value.value))) { + if (isPositiveInteger(value.value)) { return `${value.value}%` } }) let barePixels = bareValues((value: NamedUtilityValue) => { - if (!Number.isNaN(Number(value.value))) { + if (isPositiveInteger(value.value)) { return `${value.value}px` } }) let bareMilliseconds = bareValues((value: NamedUtilityValue) => { - if (!Number.isNaN(Number(value.value))) { + if (isPositiveInteger(value.value)) { return `${value.value}ms` } }) let bareDegrees = bareValues((value: NamedUtilityValue) => { - if (!Number.isNaN(Number(value.value))) { + if (isPositiveInteger(value.value)) { return `${value.value}deg` } }) @@ -45,12 +46,12 @@ let bareDegrees = bareValues((value: NamedUtilityValue) => { let bareAspectRatio = bareValues((value) => { if (value.fraction === null) return let [lhs, rhs] = segment(value.fraction, '/') - if (!Number.isInteger(Number(lhs)) || !Number.isInteger(Number(rhs))) return + if (!isPositiveInteger(lhs) || !isPositiveInteger(rhs)) return return value.fraction }) let bareRepeatValues = bareValues((value) => { - if (!Number.isNaN(Number(value.value))) { + if (isPositiveInteger(Number(value.value))) { return `repeat(${value.value}, minmax(0, 1fr))` } }) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 66312ddee..6e22534a7 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -176,6 +176,10 @@ test('inset', async () => { expect( await run([ 'inset', + 'inset--1', + 'inset--1/2', + 'inset--1/-2', + 'inset-1/-2', 'inset-auto/foo', '-inset-full/foo', 'inset-full/foo', @@ -249,6 +253,10 @@ test('inset-x', async () => { expect( await run([ 'inset-x', + 'inset-x--1', + 'inset-x--1/2', + 'inset-x--1/-2', + 'inset-x-1/-2', 'inset-x-auto/foo', 'inset-x-full/foo', '-inset-x-full/foo', @@ -322,6 +330,10 @@ test('inset-y', async () => { expect( await run([ 'inset-y', + 'inset-y--1', + 'inset-y--1/2', + 'inset-y--1/-2', + 'inset-1/-2', 'inset-y-auto/foo', 'inset-y-full/foo', '-inset-y-full/foo', @@ -388,6 +400,10 @@ test('start', async () => { expect( await run([ 'start', + 'start--1', + 'start--1/2', + 'start--1/-2', + 'start-1/-2', 'start-auto/foo', '-start-full/foo', 'start-full/foo', @@ -446,6 +462,10 @@ test('end', async () => { expect( await run([ 'end', + 'end--1', + 'end--1/2', + 'end--1/-2', + 'end-1/-2', 'end-auto/foo', '-end-full/foo', 'end-full/foo', @@ -505,6 +525,10 @@ test('top', async () => { expect( await run([ 'top', + 'top--1', + 'top--1/2', + 'top--1/-2', + 'top-1/-2', 'top-auto/foo', '-top-full/foo', 'top-full/foo', @@ -571,6 +595,10 @@ test('right', async () => { expect( await run([ 'right', + 'right--1', + 'right--1/2', + 'right--1/-2', + 'right-1/-2', 'right-auto/foo', '-right-full/foo', 'right-full/foo', @@ -637,6 +665,10 @@ test('bottom', async () => { expect( await run([ 'bottom', + 'bottom--1', + 'bottom--1/2', + 'bottom--1/-2', + 'bottom-1/-2', 'bottom-auto/foo', '-bottom-full/foo', 'bottom-full/foo', @@ -695,6 +727,10 @@ test('left', async () => { expect( await run([ 'left', + 'left--1', + 'left--1/2', + 'left--1/-2', + 'left-1/-2', 'left-auto/foo', '-left-full/foo', 'left-full/foo', @@ -746,6 +782,7 @@ test('z-index', async () => { expect( await run([ 'z', + 'z--1', '-z-auto', 'z-unknown', 'z-123.5', @@ -801,6 +838,7 @@ test('order', async () => { expect( await run([ 'order', + 'order--4', '-order-first', '-order-last', '-order-none', @@ -856,6 +894,7 @@ test('col', async () => { await run([ 'col', 'col-span', + 'col-span--1', '-col-span-4', 'col-span-unknown', 'col-auto/foo', @@ -895,6 +934,7 @@ test('col-start', async () => { expect( await run([ 'col-start', + 'col-start--1', 'col-start-unknown', 'col-start-auto/foo', 'col-start-4/foo', @@ -931,6 +971,7 @@ test('col-end', async () => { expect( await run([ 'col-end', + 'col-end--1', 'col-end-unknown', 'col-end-auto/foo', 'col-end-4/foo', @@ -980,6 +1021,7 @@ test('row', async () => { await run([ 'row', 'row-span', + 'row-span--1', '-row-span-4', 'row-span-unknown', 'row-auto/foo', @@ -1019,6 +1061,7 @@ test('row-start', async () => { expect( await run([ 'row-start', + 'row-start--1', 'row-start-unknown', 'row-start-auto/foo', 'row-start-4/foo', @@ -1055,6 +1098,7 @@ test('row-end', async () => { expect( await run([ 'row-end', + 'row-end--1', 'row-end-unknown', 'row-end-auto/foo', 'row-end-4/foo', @@ -1657,6 +1701,7 @@ test('line-clamp', async () => { expect( await run([ 'line-clamp', + 'line-clamp--4', '-line-clamp-4', '-line-clamp-[123]', '-line-clamp-none', @@ -1852,6 +1897,9 @@ test('aspect-ratio', async () => { 'aspect-video/foo', 'aspect-[10/9]/foo', 'aspect-4/3/foo', + 'aspect--4/3', + 'aspect--4/-3', + 'aspect-4/-3', ]), ).toEqual('') }) @@ -1924,6 +1972,10 @@ test('size', async () => { expect( await run([ 'size', + 'size--1', + 'size--1/2', + 'size--1/-2', + 'size-1/-2', '-size-4', '-size-1/2', '-size-[4px]', @@ -2026,6 +2078,10 @@ test('width', async () => { expect( await run([ 'w', + 'w--1', + 'w--1/2', + 'w--1/-2', + 'w-1/-2', '-w-4', '-w-1/2', '-w-[4px]', @@ -2275,6 +2331,10 @@ test('height', async () => { await run([ 'h', '-h-4', + 'h--1', + 'h--1/2', + 'h--1/-2', + 'h-1/-2', '-h-1/2', '-h-[4px]', 'h-full/foo', @@ -2520,6 +2580,7 @@ test('flex', async () => { expect( await run([ '-flex-1', + 'flex--1', '-flex-auto', '-flex-initial', '-flex-none', @@ -2527,6 +2588,9 @@ test('flex', async () => { 'flex-unknown', 'flex-1/foo', 'flex-99/foo', + 'flex--1/2', + 'flex--1/-2', + 'flex-1/-2', 'flex-1/2/foo', 'flex-auto/foo', 'flex-initial/foo', @@ -2553,6 +2617,8 @@ test('flex-shrink', async () => { expect( await run([ '-shrink', + 'shrink--1', + 'shrink-1.5', '-shrink-0', '-shrink-[123]', 'shrink-unknown', @@ -2580,6 +2646,8 @@ test('flex-grow', async () => { expect( await run([ '-grow', + 'grow--1', + 'grow-1.5', '-grow-0', '-grow-[123]', 'grow-unknown', @@ -2629,6 +2697,10 @@ test('flex-basis', async () => { expect( await run([ 'basis', + 'basis--1', + 'basis--1/2', + 'basis--1/-2', + 'basis-1/-2', '-basis-full', '-basis-[123px]', 'basis-auto/foo', @@ -3100,6 +3172,10 @@ test('translate', async () => { expect( await run([ 'translate', + 'translate--1', + 'translate--1/2', + 'translate--1/-2', + 'translate-1/-2', 'translate-1/2/foo', 'translate-full/foo', '-translate-full/foo', @@ -3169,6 +3245,10 @@ test('translate-x', async () => { expect( await run([ 'translate-x', + 'translate-x--1', + 'translate-x--1/2', + 'translate-x--1/-2', + 'translate-x-1/-2', 'translate-x-full/foo', '-translate-x-full/foo', 'translate-x-px/foo', @@ -3237,6 +3317,10 @@ test('translate-y', async () => { expect( await run([ 'translate-y', + 'translate-y--1', + 'translate-y--1/2', + 'translate-y--1/-2', + 'translate-y-1/-2', 'translate-y-full/foo', '-translate-y-full/foo', 'translate-y-px/foo', @@ -3288,6 +3372,10 @@ test('translate-z', async () => { expect( await run([ 'translate-z', + 'translate-z--1', + 'translate-z--1/2', + 'translate-z--1/-2', + 'translate-z-1/-2', 'translate-z-full', '-translate-z-full', 'translate-z-1/2', @@ -3357,6 +3445,7 @@ test('rotate', async () => { await run([ 'rotate', 'rotate-z', + 'rotate--2', 'rotate-unknown', 'rotate-45/foo', '-rotate-45/foo', @@ -3428,6 +3517,7 @@ test('rotate-x', async () => { expect( await run([ 'rotate-x', + 'rotate-x--1', '-rotate-x', 'rotate-x-potato', 'rotate-x-45/foo', @@ -3499,6 +3589,7 @@ test('rotate-y', async () => { expect( await run([ 'rotate-y', + 'rotate-y--1', '-rotate-y', 'rotate-y-potato', 'rotate-y-45/foo', @@ -3571,7 +3662,14 @@ test('skew', async () => { }" `) expect( - await run(['skew', 'skew-unknown', 'skew-6/foo', '-skew-6/foo', 'skew-[123deg]/foo']), + await run([ + 'skew', + 'skew--1', + 'skew-unknown', + 'skew-6/foo', + '-skew-6/foo', + 'skew-[123deg]/foo', + ]), ).toEqual('') }) @@ -3635,7 +3733,14 @@ test('skew-x', async () => { }" `) expect( - await run(['skew-x', 'skew-x-unknown', 'skew-x-6/foo', '-skew-x-6/foo', 'skew-x-[123deg]/foo']), + await run([ + 'skew-x', + 'skew-x--1', + 'skew-x-unknown', + 'skew-x-6/foo', + '-skew-x-6/foo', + 'skew-x-[123deg]/foo', + ]), ).toEqual('') }) @@ -3699,7 +3804,14 @@ test('skew-y', async () => { }" `) expect( - await run(['skew-y', 'skew-y-unknown', 'skew-y-6/foo', '-skew-y-6/foo', 'skew-y-[123deg]/foo']), + await run([ + 'skew-y', + 'skew-y--1', + 'skew-y-unknown', + 'skew-y-6/foo', + '-skew-y-6/foo', + 'skew-y-[123deg]/foo', + ]), ).toEqual('') }) @@ -3759,6 +3871,8 @@ test('scale', async () => { expect( await run([ 'scale', + 'scale--50', + 'scale-1.5', 'scale-unknown', 'scale-50/foo', '-scale-50/foo', @@ -3894,6 +4008,8 @@ test('scale-x', async () => { expect( await run([ 'scale-x', + 'scale-x--1', + 'scale-x-1.5', 'scale-x-unknown', 'scale-200/foo', 'scale-x-400/foo', @@ -3952,6 +4068,8 @@ test('scale-y', async () => { expect( await run([ 'scale-y', + 'scale-y--1', + 'scale-y-1.5', 'scale-y-unknown', 'scale-y-50/foo', '-scale-y-50/foo', @@ -4006,7 +4124,14 @@ test('scale-z', async () => { }" `) expect( - await run(['scale-z', 'scale-z-50/foo', '-scale-z-50/foo', 'scale-z-[123deg]/foo']), + await run([ + 'scale-z', + 'scale-z--1', + 'scale-z-1.5', + 'scale-z-50/foo', + '-scale-z-50/foo', + 'scale-z-[123deg]/foo', + ]), ).toEqual('') }) @@ -5767,6 +5892,7 @@ test('columns', async () => { expect( await run([ 'columns', + 'columns--4', '-columns-4', '-columns-[123]', '-columns-[--value]', @@ -6128,6 +6254,7 @@ test('grid-cols', async () => { 'grid-cols', '-grid-cols-none', '-grid-cols-subgrid', + 'grid-cols--12', '-grid-cols-12', '-grid-cols-[123]', 'grid-cols-unknown', @@ -6175,6 +6302,7 @@ test('grid-rows', async () => { 'grid-rows', '-grid-rows-none', '-grid-rows-subgrid', + 'grid-rows--12', '-grid-rows-12', '-grid-rows-[123]', 'grid-rows-unknown', @@ -6868,6 +6996,7 @@ test('divide-x', async () => { expect( await run([ '-divide-x', + 'divide-x--4', '-divide-x-4', '-divide-x-123', 'divide-x-unknown', @@ -6986,6 +7115,7 @@ test('divide-y', async () => { expect( await run([ '-divide-y', + 'divide-y--4', '-divide-y-4', '-divide-y-123', 'divide-y-unknown', @@ -7203,6 +7333,7 @@ test('accent', async () => { await run([ 'accent', '-accent-red-500', + 'accent-red-500/-50', '-accent-red-500/50', '-accent-red-500/[0.5]', '-accent-red-500/[50%]', @@ -7212,6 +7343,7 @@ test('accent', async () => { '-accent-current/[50%]', '-accent-inherit', '-accent-transparent', + 'accent-[#0088cc]/-50', '-accent-[#0088cc]', '-accent-[#0088cc]/50', '-accent-[#0088cc]/[0.5]', @@ -9819,6 +9951,8 @@ test('from', async () => { await run([ 'from', 'from-123', + 'from--123', + 'from--5%', 'from-unknown', 'from-unknown%', @@ -10056,6 +10190,8 @@ test('via', async () => { await run([ 'via', 'via-123', + 'via--123', + 'via--5%', 'via-unknown', 'via-unknown%', @@ -10281,6 +10417,8 @@ test('to', async () => { await run([ 'to', 'to-123', + 'to--123', + 'to--5%', 'to-unknown', 'to-unknown%', @@ -10895,6 +11033,7 @@ test('stroke', async () => { // Width '-stroke-0', + 'stroke--1', ]), ).toEqual('') }) @@ -11917,6 +12056,7 @@ test('decoration', async () => { '-decoration-wavy', // text-decoration-thickness + 'decoration--2', '-decoration-auto', '-decoration-from-font', '-decoration-0', @@ -12248,28 +12388,35 @@ test('filter', async () => { '-filter-[--value]', '-blur-xl', '-blur-[4px]', + 'brightness--50', '-brightness-50', '-brightness-[1.23]', 'brightness-unknown', + 'contrast--50', '-contrast-50', '-contrast-[1.23]', 'contrast-unknown', '-grayscale', '-grayscale-0', + 'grayscale--1', '-grayscale-[--value]', 'grayscale-unknown', + 'hue-rotate--5', 'hue-rotate-unknown', '-invert', + 'invert--5', '-invert-0', '-invert-[--value]', 'invert-unknown', '-drop-shadow-xl', '-drop-shadow-[0_0_red]', '-saturate-0', + 'saturate--5', '-saturate-[1.75]', '-saturate-[--value]', 'saturate-saturate', '-sepia', + 'sepia--50', '-sepia-0', '-sepia-[50%]', '-sepia-[--value]', @@ -12597,29 +12744,36 @@ test('backdrop-filter', async () => { '-backdrop-filter-[--value]', '-backdrop-blur-xl', '-backdrop-blur-[4px]', + 'backdrop-brightness--50', '-backdrop-brightness-50', '-backdrop-brightness-[1.23]', 'backdrop-brightness-unknown', + 'backdrop-contrast--50', '-backdrop-contrast-50', '-backdrop-contrast-[1.23]', 'backdrop-contrast-unknown', '-backdrop-grayscale', + 'backdrop-grayscale--1', '-backdrop-grayscale-0', '-backdrop-grayscale-[--value]', 'backdrop-grayscale-unknown', 'backdrop-hue-rotate-unknown', '-backdrop-invert', + 'backdrop-invert--1', '-backdrop-invert-0', '-backdrop-invert-[--value]', 'backdrop-invert-unknown', + 'backdrop-opacity--50', '-backdrop-opacity-50', '-backdrop-opacity-[0.5]', 'backdrop-opacity-unknown', '-backdrop-saturate-0', + 'backdrop-saturate--50', '-backdrop-saturate-[1.75]', '-backdrop-saturate-[--value]', 'backdrop-saturate-unknown', '-backdrop-sepia', + 'backdrop-sepia--50', '-backdrop-sepia-0', '-backdrop-sepia-[50%]', '-backdrop-sepia-[--value]', @@ -12637,6 +12791,7 @@ test('backdrop-filter', async () => { 'backdrop-grayscale/foo', 'backdrop-grayscale-0/foo', 'backdrop-grayscale-[--value]/foo', + 'backdrop-hue-rotate--15', 'backdrop-hue-rotate-15/foo', 'backdrop-hue-rotate-[45deg]/foo', 'backdrop-invert/foo', @@ -12789,6 +12944,7 @@ test('delay', async () => { expect( await run([ 'delay', + 'delay--200', '-delay-200', '-delay-[300ms]', 'delay-unknown', @@ -12816,6 +12972,7 @@ test('duration', async () => { expect( await run([ 'duration', + 'duration--200', '-duration-200', '-duration-[300ms]', 'duration-123/foo', @@ -13543,6 +13700,7 @@ test('outline', async () => { // outline-width '-outline-0', + 'outline--10', 'outline/foo', 'outline-none/foo', @@ -13582,6 +13740,7 @@ test('outline-offset', async () => { expect( await run([ 'outline-offset', + 'outline-offset--4', 'outline-offset-unknown', 'outline-offset-4/foo', '-outline-offset-4/foo', @@ -13604,6 +13763,7 @@ test('opacity', async () => { expect( await run([ 'opacity', + 'opacity--15', '-opacity-15', '-opacity-[--value]', 'opacity-unknown', @@ -13663,6 +13823,7 @@ test('underline-offset', async () => { expect( await run([ 'underline-offset', + 'underline-offset--4', '-underline-offset-auto', 'underline-offset-unknown', 'underline-offset-auto/foo', @@ -14642,6 +14803,7 @@ test('ring', async () => { // ring width '-ring', + 'ring--1', '-ring-0', '-ring-1', '-ring-2', @@ -14901,6 +15063,7 @@ test('inset-ring', async () => { // ring width '-inset-ring', + 'inset-ring--1', '-inset-ring-0', '-inset-ring-1', '-inset-ring-2', @@ -15066,6 +15229,7 @@ test('ring-offset', async () => { '-ring-offset-[#0088cc]/[50%]', // ring width + 'ring-offset--1', '-ring-offset-0', '-ring-offset-1', '-ring-offset-2', diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 9a73c1ac0..95f8a1dd6 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -2,7 +2,7 @@ import { decl, rule, type AstNode, type Rule } from './ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate' import type { Theme, ThemeKey } from './theme' import { DefaultMap } from './utils/default-map' -import { inferDataType } from './utils/infer-data-type' +import { inferDataType, isPositiveInteger } from './utils/infer-data-type' import { replaceShadowColors } from './utils/replace-shadow-colors' import { segment } from './utils/segment' @@ -136,7 +136,7 @@ export function asColor(value: string, modifier: CandidateModifier | null): stri return withAlpha(value, modifier.value) } - if (Number.isNaN(Number(modifier.value))) { + if (!isPositiveInteger(modifier.value)) { return null } @@ -295,7 +295,7 @@ export function createUtilities(theme: Theme) { // exist as a theme value. if (value === null && desc.supportsFractions && candidate.value.fraction) { let [lhs, rhs] = segment(candidate.value.fraction, '/') - if (!Number.isInteger(Number(lhs)) || !Number.isInteger(Number(rhs))) return + if (!isPositiveInteger(lhs) || !isPositiveInteger(rhs)) return value = `calc(${candidate.value.fraction} * 100%)` } @@ -455,7 +455,7 @@ export function createUtilities(theme: Theme) { if (!value && candidate.value.fraction) { let [lhs, rhs] = segment(candidate.value.fraction, '/') - if (!Number.isInteger(Number(lhs)) || !Number.isInteger(Number(rhs))) return + if (!isPositiveInteger(lhs) || !isPositiveInteger(rhs)) return value = `calc(${candidate.value.fraction} * 100%)` } @@ -598,7 +598,7 @@ export function createUtilities(theme: Theme) { functionalUtility('z', { supportsNegative: true, handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, themeKeys: ['--z-index'], @@ -622,7 +622,7 @@ export function createUtilities(theme: Theme) { functionalUtility('order', { supportsNegative: true, handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, themeKeys: ['--order'], @@ -648,7 +648,7 @@ export function createUtilities(theme: Theme) { staticUtility('col-span-full', [['grid-column', '1 / -1']]) functionalUtility('col-span', { handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, handle: (value) => [decl('grid-column', `span ${value} / span ${value}`)], @@ -661,7 +661,7 @@ export function createUtilities(theme: Theme) { functionalUtility('col-start', { supportsNegative: true, handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, themeKeys: ['--grid-column-start'], @@ -675,7 +675,7 @@ export function createUtilities(theme: Theme) { functionalUtility('col-end', { supportsNegative: true, handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, themeKeys: ['--grid-column-end'], @@ -717,7 +717,7 @@ export function createUtilities(theme: Theme) { functionalUtility('row-span', { themeKeys: [], handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, handle: (value) => [decl('grid-row', `span ${value} / span ${value}`)], @@ -730,7 +730,7 @@ export function createUtilities(theme: Theme) { functionalUtility('row-start', { supportsNegative: true, handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, themeKeys: ['--grid-row-start'], @@ -744,7 +744,7 @@ export function createUtilities(theme: Theme) { functionalUtility('row-end', { supportsNegative: true, handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, themeKeys: ['--grid-row-end'], @@ -839,7 +839,7 @@ export function createUtilities(theme: Theme) { functionalUtility('line-clamp', { themeKeys: ['--line-clamp'], handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, handle: (value) => [ @@ -893,7 +893,7 @@ export function createUtilities(theme: Theme) { handleBareValue: ({ fraction }) => { if (fraction === null) return null let [lhs, rhs] = segment(fraction, '/') - if (!Number.isInteger(Number(lhs)) || !Number.isInteger(Number(rhs))) return null + if (!isPositiveInteger(lhs) || !isPositiveInteger(rhs)) return null return fraction }, handle: (value) => [decl('aspect-ratio', value)], @@ -1068,11 +1068,11 @@ export function createUtilities(theme: Theme) { if (candidate.value.fraction) { let [lhs, rhs] = segment(candidate.value.fraction, '/') - if (!Number.isInteger(Number(lhs)) || !Number.isInteger(Number(rhs))) return + if (!isPositiveInteger(lhs) || !isPositiveInteger(rhs)) return return [decl('flex', `calc(${candidate.value.fraction} * 100%)`)] } - if (Number.isInteger(Number(candidate.value.value))) { + if (isPositiveInteger(candidate.value.value)) { if (candidate.modifier) return return [decl('flex', candidate.value.value)] } @@ -1084,7 +1084,7 @@ export function createUtilities(theme: Theme) { functionalUtility('shrink', { defaultValue: '1', handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, handle: (value) => [decl('flex-shrink', value)], @@ -1096,7 +1096,7 @@ export function createUtilities(theme: Theme) { functionalUtility('grow', { defaultValue: '1', handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, handle: (value) => [decl('flex-grow', value)], @@ -1337,7 +1337,7 @@ export function createUtilities(theme: Theme) { return [decl('scale', value)] } else { value = theme.resolve(candidate.value.value, ['--scale']) - if (!value && !Number.isNaN(Number(candidate.value.value))) { + if (!value && isPositiveInteger(candidate.value.value)) { value = `${candidate.value.value}%` } if (!value) return @@ -1368,7 +1368,7 @@ export function createUtilities(theme: Theme) { supportsNegative: true, themeKeys: ['--scale'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, handle: (value) => [ @@ -1420,7 +1420,7 @@ export function createUtilities(theme: Theme) { } } else { value = theme.resolve(candidate.value.value, ['--rotate']) - if (!value && !Number.isNaN(Number(candidate.value.value))) { + if (!value && isPositiveInteger(candidate.value.value)) { value = `${candidate.value.value}deg` } if (!value) return @@ -1460,7 +1460,7 @@ export function createUtilities(theme: Theme) { supportsNegative: true, themeKeys: ['--rotate'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `rotate${axis.toUpperCase()}(${value}deg)` }, handle: (value) => [ @@ -1487,7 +1487,7 @@ export function createUtilities(theme: Theme) { supportsNegative: true, themeKeys: ['--skew'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}deg` }, handle: (value) => [ @@ -1506,7 +1506,7 @@ export function createUtilities(theme: Theme) { supportsNegative: true, themeKeys: ['--skew'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}deg` }, handle: (value) => [ @@ -1524,7 +1524,7 @@ export function createUtilities(theme: Theme) { supportsNegative: true, themeKeys: ['--skew'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}deg` }, handle: (value) => [ @@ -1872,7 +1872,7 @@ export function createUtilities(theme: Theme) { functionalUtility('columns', { themeKeys: ['--columns', '--width'], handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return value }, handle: (value) => [decl('columns', value)], @@ -1926,7 +1926,7 @@ export function createUtilities(theme: Theme) { functionalUtility('grid-cols', { themeKeys: ['--grid-template-columns'], handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return `repeat(${value}, minmax(0, 1fr))` }, handle: (value) => [decl('grid-template-columns', value)], @@ -1937,7 +1937,7 @@ export function createUtilities(theme: Theme) { functionalUtility('grid-rows', { themeKeys: ['--grid-template-rows'], handleBareValue: ({ value }) => { - if (!Number.isInteger(Number(value))) return null + if (!isPositiveInteger(value)) return null return `repeat(${value}, minmax(0, 1fr))` }, handle: (value) => [decl('grid-template-rows', value)], @@ -2292,7 +2292,7 @@ export function createUtilities(theme: Theme) { return [borderProperties(), ...decls] } - if (!Number.isNaN(Number(candidate.value.value))) { + if (isPositiveInteger(candidate.value.value)) { let decls = desc.width(`${candidate.value.value}px`) if (!decls) return return [borderProperties(), ...decls] @@ -2394,7 +2394,7 @@ export function createUtilities(theme: Theme) { defaultValue: theme.get(['--default-border-width']) ?? '1px', themeKeys: ['--divide-width', '--border-width'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}px` }, handle: (value) => [ @@ -2414,7 +2414,7 @@ export function createUtilities(theme: Theme) { defaultValue: theme.get(['--default-border-width']) ?? '1px', themeKeys: ['--divide-width', '--border-width'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}px` }, handle: (value) => [ @@ -2672,7 +2672,7 @@ export function createUtilities(theme: Theme) { return desc.position(value) } else if ( candidate.value.value[candidate.value.value.length - 1] === '%' && - !Number.isNaN(Number(candidate.value.value.slice(0, -1))) + isPositiveInteger(candidate.value.value.slice(0, -1)) ) { return desc.position(candidate.value.value) } @@ -2841,7 +2841,7 @@ export function createUtilities(theme: Theme) { let value = theme.resolve(candidate.value.value, ['--stroke-width']) if (value) { return [decl('stroke-width', value)] - } else if (!Number.isNaN(Number(candidate.value.value))) { + } else if (isPositiveInteger(candidate.value.value)) { return [decl('stroke-width', candidate.value.value)] } } @@ -3147,7 +3147,7 @@ export function createUtilities(theme: Theme) { return [decl('text-decoration-thickness', value)] } - if (!Number.isNaN(Number(candidate.value.value))) { + if (isPositiveInteger(candidate.value.value)) { if (candidate.modifier) return return [decl('text-decoration-thickness', `${candidate.value.value}px`)] } @@ -3305,7 +3305,7 @@ export function createUtilities(theme: Theme) { functionalUtility('brightness', { themeKeys: ['--brightness'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, handle: (value) => [ @@ -3318,7 +3318,7 @@ export function createUtilities(theme: Theme) { functionalUtility('backdrop-brightness', { themeKeys: ['--backdrop-brightness', '--brightness'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, handle: (value) => [ @@ -3346,7 +3346,7 @@ export function createUtilities(theme: Theme) { functionalUtility('contrast', { themeKeys: ['--contrast'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, handle: (value) => [ @@ -3359,7 +3359,7 @@ export function createUtilities(theme: Theme) { functionalUtility('backdrop-contrast', { themeKeys: ['--backdrop-contrast', '--contrast'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, handle: (value) => [ @@ -3387,7 +3387,7 @@ export function createUtilities(theme: Theme) { functionalUtility('grayscale', { themeKeys: ['--grayscale'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, defaultValue: '100%', @@ -3401,7 +3401,7 @@ export function createUtilities(theme: Theme) { functionalUtility('backdrop-grayscale', { themeKeys: ['--backdrop-grayscale', '--grayscale'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, defaultValue: '100%', @@ -3433,7 +3433,7 @@ export function createUtilities(theme: Theme) { supportsNegative: true, themeKeys: ['--hue-rotate'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}deg` }, handle: (value) => [ @@ -3447,7 +3447,7 @@ export function createUtilities(theme: Theme) { supportsNegative: true, themeKeys: ['--backdrop-hue-rotate', '--hue-rotate'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}deg` }, handle: (value) => [ @@ -3475,7 +3475,7 @@ export function createUtilities(theme: Theme) { functionalUtility('invert', { themeKeys: ['--invert'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, defaultValue: '100%', @@ -3489,7 +3489,7 @@ export function createUtilities(theme: Theme) { functionalUtility('backdrop-invert', { themeKeys: ['--backdrop-invert', '--invert'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, defaultValue: '100%', @@ -3520,7 +3520,7 @@ export function createUtilities(theme: Theme) { functionalUtility('saturate', { themeKeys: ['--saturate'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, handle: (value) => [ @@ -3533,7 +3533,7 @@ export function createUtilities(theme: Theme) { functionalUtility('backdrop-saturate', { themeKeys: ['--backdrop-saturate', '--saturate'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, handle: (value) => [ @@ -3561,7 +3561,7 @@ export function createUtilities(theme: Theme) { functionalUtility('sepia', { themeKeys: ['--sepia'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, defaultValue: '100%', @@ -3575,7 +3575,7 @@ export function createUtilities(theme: Theme) { functionalUtility('backdrop-sepia', { themeKeys: ['--backdrop-sepia', '--sepia'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, defaultValue: '100%', @@ -3620,7 +3620,7 @@ export function createUtilities(theme: Theme) { functionalUtility('backdrop-opacity', { themeKeys: ['--backdrop-opacity', '--opacity'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, handle: (value) => [ @@ -3686,7 +3686,7 @@ export function createUtilities(theme: Theme) { functionalUtility('delay', { handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}ms` }, themeKeys: ['--transition-delay'], @@ -3713,7 +3713,7 @@ export function createUtilities(theme: Theme) { '--transition-duration', ]) - if (value === null && !Number.isNaN(Number(candidate.value.value))) { + if (value === null && isPositiveInteger(candidate.value.value)) { value = `${candidate.value.value}ms` } } @@ -4001,7 +4001,7 @@ export function createUtilities(theme: Theme) { decl('outline-style', 'var(--tw-outline-style)'), decl('outline-width', value), ] - } else if (!Number.isNaN(Number(candidate.value.value))) { + } else if (isPositiveInteger(candidate.value.value)) { return [ outlineProperties(), decl('outline-style', 'var(--tw-outline-style)'), @@ -4028,7 +4028,7 @@ export function createUtilities(theme: Theme) { supportsNegative: true, themeKeys: ['--outline-offset'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}px` }, handle: (value) => [decl('outline-offset', value)], @@ -4045,7 +4045,7 @@ export function createUtilities(theme: Theme) { functionalUtility('opacity', { themeKeys: ['--opacity'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}%` }, handle: (value) => [decl('opacity', value)], @@ -4063,7 +4063,7 @@ export function createUtilities(theme: Theme) { supportsNegative: true, themeKeys: ['--text-underline-offset'], handleBareValue: ({ value }) => { - if (Number.isNaN(Number(value))) return null + if (!isPositiveInteger(value)) return null return `${value}px` }, handle: (value) => [decl('text-underline-offset', value)], @@ -4460,7 +4460,7 @@ export function createUtilities(theme: Theme) { { if (candidate.modifier) return let value = theme.resolve(candidate.value.value, ['--ring-width']) - if (value === null && !Number.isNaN(Number(candidate.value.value))) { + if (value === null && isPositiveInteger(candidate.value.value)) { value = `${candidate.value.value}px` } if (value) { @@ -4535,7 +4535,7 @@ export function createUtilities(theme: Theme) { { if (candidate.modifier) return let value = theme.resolve(candidate.value.value, ['--ring-width']) - if (value === null && !Number.isNaN(Number(candidate.value.value))) { + if (value === null && isPositiveInteger(candidate.value.value)) { value = `${candidate.value.value}px` } if (value) { @@ -4596,7 +4596,7 @@ export function createUtilities(theme: Theme) { decl('--tw-ring-offset-width', value), decl('--tw-ring-offset-shadow', ringOffsetShadowValue), ] - } else if (!Number.isNaN(Number(candidate.value.value))) { + } else if (isPositiveInteger(candidate.value.value)) { if (candidate.modifier) return return [ decl('--tw-ring-offset-width', `${candidate.value.value}px`), diff --git a/packages/tailwindcss/src/utils/infer-data-type.ts b/packages/tailwindcss/src/utils/infer-data-type.ts index 380c08ac0..591f66173 100644 --- a/packages/tailwindcss/src/utils/infer-data-type.ts +++ b/packages/tailwindcss/src/utils/infer-data-type.ts @@ -320,3 +320,11 @@ const IS_VECTOR = new RegExp(`^${HAS_NUMBER.source} +${HAS_NUMBER.source} +${HAS function isVector(value: string) { return IS_VECTOR.test(value) } + +/** + * Returns true of the value can be parsed as a positive whole number. + */ +export function isPositiveInteger(value: any) { + let num = Number(value) + return Number.isInteger(num) && num >= 0 +} diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 40042045c..cb67e48d5 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -2252,14 +2252,18 @@ test('nth', async () => { ).toEqual('') expect( await run([ + 'nth--3:flex', 'nth-3/foo:flex', 'nth-[2n+1]/foo:flex', 'nth-[2n+1_of_.foo]/foo:flex', + 'nth-last--3:flex', 'nth-last-3/foo:flex', 'nth-last-[2n+1]/foo:flex', 'nth-last-[2n+1_of_.foo]/foo:flex', + 'nth-of-type--3:flex', 'nth-of-type-3/foo:flex', 'nth-of-type-[2n+1]/foo:flex', + 'nth-last-of-type--3:flex', 'nth-last-of-type-3/foo:flex', 'nth-last-of-type-[2n+1]/foo:flex', ]), diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 2bb9def3d..a823ec6ed 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -2,6 +2,7 @@ import { WalkAction, decl, rule, walk, type AstNode, type Rule } from './ast' import { type Variant } from './candidate' import type { Theme } from './theme' import { DefaultMap } from './utils/default-map' +import { isPositiveInteger } from './utils/infer-data-type' import { segment } from './utils/segment' type VariantFn = ( @@ -538,7 +539,7 @@ export function createVariants(theme: Theme): Variants { if (!variant.value || variant.modifier) return null // Only numeric bare values are allowed - if (variant.value.kind === 'named' && Number.isNaN(Number(variant.value.value))) return null + if (variant.value.kind === 'named' && !isPositiveInteger(variant.value.value)) return null ruleNode.nodes = [rule(`&:nth-child(${variant.value.value})`, ruleNode.nodes)] }) @@ -547,7 +548,7 @@ export function createVariants(theme: Theme): Variants { if (!variant.value || variant.modifier) return null // Only numeric bare values are allowed - if (variant.value.kind === 'named' && Number.isNaN(Number(variant.value.value))) return null + if (variant.value.kind === 'named' && !isPositiveInteger(variant.value.value)) return null ruleNode.nodes = [rule(`&:nth-last-child(${variant.value.value})`, ruleNode.nodes)] }) @@ -556,7 +557,7 @@ export function createVariants(theme: Theme): Variants { if (!variant.value || variant.modifier) return null // Only numeric bare values are allowed - if (variant.value.kind === 'named' && Number.isNaN(Number(variant.value.value))) return null + if (variant.value.kind === 'named' && !isPositiveInteger(variant.value.value)) return null ruleNode.nodes = [rule(`&:nth-of-type(${variant.value.value})`, ruleNode.nodes)] }) @@ -565,7 +566,7 @@ export function createVariants(theme: Theme): Variants { if (!variant.value || variant.modifier) return null // Only numeric bare values are allowed - if (variant.value.kind === 'named' && Number.isNaN(Number(variant.value.value))) return null + if (variant.value.kind === 'named' && !isPositiveInteger(variant.value.value)) return null ruleNode.nodes = [rule(`&:nth-last-of-type(${variant.value.value})`, ruleNode.nodes)] })