Skip to content

Commit f82ac39

Browse files
authored
Improve @utility name validation (#19524)
This PR improves the validation of allowed `@utility …` names. Each `@utility` name should be a valid Tailwind CSS class, so new syntaxes should not be allowed, e.g. `foo/bar/baz` would be invalid. We already enforce this behavior but not consistently. The Oxide scanner that scans all your source files for potential Tailwind CSS classes does enforce all of these rules already. So if you used `@utility foo/bar/baz {}`, the Oxide scanner would not pick up `foo/bar/baz` as a valid class name, so for that reason it's not a breaking change. Where we didn't enforce it is in places where you use the development-only CDN or Tailwind Play. That's because those environments don't use the Oxide at all, and get the classes from the DOM directly and pass it to Tailwind's compiler. This PR moves some of these validation rules into Tailwind's core when defining custom `@utility` utilities. Fixes: #19505 ### Test plan 1. Existing tests still pass 2. Added a regression test for the linked issue 3. Added new tests with valid / invalid `@utility` names I also confirmed with Oxide to know which classes were actually valid and which ones are invalid. Given this input CSS: ```css @Utility foo { color: red } @Utility foo_ { color: red } /* This one looks invalid to me, but it works today */ /* and I don't want to introduce unnecessary breaking changes. */ @Utility foo-1.5 { color: red } @Utility foo-123 { color: red } @Utility -foo { color: red } @Utility foo-bar { color: red } @Utility foo_bar { color: red } @Utility foo-50% { color: red } @Utility foo-1/2 { color: red } ``` And this HTML: ```html <!-- Extracted: --> <div class="foo foo_ foo-123 -foo foo-bar foo_bar foo-50% foo-1/2 foo-1.5"></div> <!-- Not Extracted: --> <div class="Foo -Foo foo-1/ foo- foo-p% foo-1..5 foo.bar foo..bar "></div> ``` Then all classes in the `Extracted` section are found. One funny thing in the not extracted section is that the `bar` in `foo.bar` and `foo..bar` is also extracted. Feels like a potential bug, but out of scope for this PR. <img width="766" height="164" alt="image" src="https://github.com/user-attachments/assets/fafbaa35-2730-4f61-9b15-6690b02ec686" />
1 parent dc432aa commit f82ac39

4 files changed

Lines changed: 222 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- CLI: Emit comment when source maps are saved to files ([#19447](https://github.com/tailwindlabs/tailwindcss/pull/19447))
1515
- Detect utilities when containing capital letters followed by numbers ([#19465](https://github.com/tailwindlabs/tailwindcss/pull/19465))
1616
- Fix class extraction for Rails' strict locals ([#19525](https://github.com/tailwindlabs/tailwindcss/pull/19525))
17+
- Align `@utility` name validation with Oxide scanner rules ([#19524](https://github.com/tailwindlabs/tailwindcss/pull/19524))
1718

1819
### Added
1920

packages/tailwindcss/src/index.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4660,6 +4660,41 @@ describe('@utility', () => {
46604660
`[Error: \`@utility my-*-utility\` defines an invalid utility name. The dynamic portion marked by \`-*\` must appear once at the end.]`,
46614661
)
46624662
})
4663+
4664+
// https://github.com/tailwindlabs/tailwindcss/issues/19505
4665+
test('@utility name cannot contain multiple `/` characters', async () => {
4666+
await expect(
4667+
compileCss(
4668+
css`
4669+
@utility ui/button {
4670+
display: inline-flex;
4671+
background: blue;
4672+
}
4673+
@tailwind utilities;
4674+
`,
4675+
['ui/button'],
4676+
),
4677+
).resolves.toMatchInlineSnapshot(
4678+
`
4679+
".ui\\/button {
4680+
background: #00f;
4681+
display: inline-flex;
4682+
}"
4683+
`,
4684+
)
4685+
4686+
await expect(
4687+
compileCss(css`
4688+
@utility ui/button/sm {
4689+
display: inline-flex;
4690+
background: blue;
4691+
font-size: 12px;
4692+
}
4693+
`),
4694+
).rejects.toThrowErrorMatchingInlineSnapshot(
4695+
`[Error: \`@utility ui/button/sm\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.]`,
4696+
)
4697+
})
46634698
})
46644699

46654700
test('addBase', async () => {

packages/tailwindcss/src/utilities.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test, vi } from 'vitest'
22
import { compile } from '.'
33
import { compileCss, optimizeCss, run } from './test-utils/run'
4+
import { isValidFunctionalUtilityName, isValidStaticUtilityName } from './utilities'
45

56
const css = String.raw
67

@@ -27207,6 +27208,48 @@ describe('spacing utilities', () => {
2720727208
})
2720827209

2720927210
describe('custom utilities', () => {
27211+
test.each([
27212+
['foo', true], // Simple name
27213+
['foo-123', true], // Ending with a number is valid
27214+
['foo-2.5', true], // Dots are valid when surrounded by numbers
27215+
['-foo', true], // Simple name with negative sign
27216+
['foo-bar', true], // With dashes
27217+
['foo_bar', true], // With underscores
27218+
['foo-50%', true], // Bare value with percentage
27219+
['foo-1/2', true], // Bare value with fraction
27220+
['foo-sm/8', true], // Bare value with number modifier
27221+
['foo-4/snug', true], // Bare value with named modifier
27222+
['foo_', true], // This is supported today, so let's not break it
27223+
['foo/bar', true], // A slash to separate the modifier is valid.
27224+
27225+
['Foo', false], // Starting with uppercase letter is invalid
27226+
['-Foo', false], // Starting with uppercase letter is invalid (negative)
27227+
['foo-', false], // Should not end with a dash
27228+
['foo-1/', false], // Invalid fraction/modifier
27229+
['foo-p%', false], // Invalid percentage
27230+
['foo.bar', false], // Dots are only valid when surrounded by numbers
27231+
['foo-1..5', false], // Double dots are invalid
27232+
['foo..bar', false], // Double dots are invalid definitely without numbers
27233+
['foo/bar/baz', false], // Multiple slashes are invalid
27234+
])('valid static utility name "%s" (%s)', (name, valid) => {
27235+
expect(isValidStaticUtilityName(name)).toBe(valid)
27236+
})
27237+
27238+
test.each([
27239+
['foo', false], // Simple name, missing '-*' suffix
27240+
['foo-*', true], // Simple name
27241+
['foo--*', false], // Root should not end in `-`
27242+
['-foo-*', true], // Simple name (negative)
27243+
['foo-bar-*', true], // With dashes
27244+
['foo_bar-*', true], // With underscores
27245+
['Foo-*', false], // Starting with uppercase letter is invalid
27246+
['-Foo-*', false], // Starting with uppercase letter is invalid
27247+
['foo!-*', false], // Invalid special character
27248+
['foo-[…]', false], // Invalid special character
27249+
])('valid functional name "%s" (%s)', (name, valid) => {
27250+
expect(isValidFunctionalUtilityName(name)).toBe(valid)
27251+
})
27252+
2721027253
test('custom static utility', async () => {
2721127254
let { build } = await compile(css`
2721227255
@layer utilities {

packages/tailwindcss/src/utilities.ts

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@ import { segment } from './utils/segment'
2828
import * as ValueParser from './value-parser'
2929
import { walk, WalkAction } from './walk'
3030

31-
const IS_VALID_STATIC_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*$/
32-
const IS_VALID_FUNCTIONAL_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*-\*$/
33-
3431
const DEFAULT_SPACING_SUGGESTIONS = [
3532
'0',
3633
'0.5',
@@ -5835,7 +5832,7 @@ export function createCssUtility(node: AtRule) {
58355832
let name = node.params
58365833

58375834
// Functional utilities. E.g.: `tab-size-*`
5838-
if (IS_VALID_FUNCTIONAL_UTILITY_NAME.test(name)) {
5835+
if (isValidFunctionalUtilityName(name)) {
58395836
// API:
58405837
//
58415838
// - `--value('literal')` resolves a literal named value
@@ -6184,7 +6181,7 @@ export function createCssUtility(node: AtRule) {
61846181
}
61856182
}
61866183

6187-
if (IS_VALID_STATIC_UTILITY_NAME.test(name)) {
6184+
if (isValidStaticUtilityName(name)) {
61886185
return (designSystem: DesignSystem) => {
61896186
designSystem.utilities.static(name, () => node.nodes.map(cloneAstNode))
61906187
}
@@ -6428,3 +6425,144 @@ function alphaReplacedDropShadowProperties(
64286425
return [decl(property, prefix + replacedValue)]
64296426
}
64306427
}
6428+
6429+
const UTILITY_ROOT = /^-?[a-z][a-zA-Z0-9_-]*/
6430+
6431+
const PERCENT = 37
6432+
const SLASH = 47
6433+
const DOT = 46
6434+
const LOWER_A = 97
6435+
const LOWER_Z = 122
6436+
const UPPER_A = 65
6437+
const UPPER_Z = 90
6438+
const ZERO = 48
6439+
const NINE = 57
6440+
const UNDERSCORE = 95
6441+
const DASH = 45
6442+
6443+
export function isValidStaticUtilityName(name: string): boolean {
6444+
let match = UTILITY_ROOT.exec(name)
6445+
if (match === null) return false // Invalid root
6446+
6447+
let root = match[0]
6448+
let value = name.slice(root.length)
6449+
6450+
// Root should not end in `-` if there is no value
6451+
//
6452+
// `tab-size-`
6453+
// --------- Root
6454+
if (value.length === 0 && root.endsWith('-')) {
6455+
return false
6456+
}
6457+
6458+
// No remaining value is valid
6459+
//
6460+
// `tab-size`
6461+
// -------- Root
6462+
if (value.length === 0) {
6463+
return true
6464+
}
6465+
6466+
// Any valid (static) utility should be valid including:
6467+
// - Bare values with `.`: `p-1.5`
6468+
// - Bare values with `%`: `w-50%`
6469+
// - With an embedded modifier: `text-xs/8`
6470+
6471+
let seenSlash = false
6472+
for (let i = 0; i < value.length; i++) {
6473+
let charCode = value.charCodeAt(i)
6474+
switch (charCode) {
6475+
case PERCENT: {
6476+
// A percentage is only valid at the end of the value
6477+
if (i !== value.length - 1) return false
6478+
6479+
// A percent is only valid when preceded by a digit. E.g.: `w-%` is invalid
6480+
let previousChar = value[i - 1] || root[root.length - 1] || ''
6481+
let previousCharCode = previousChar.charCodeAt(0)
6482+
if (previousCharCode < ZERO || previousCharCode > NINE) return false
6483+
break
6484+
}
6485+
6486+
case SLASH: {
6487+
// A slash must be followed by at least 1 character. E.g.: `foo/` is invalid
6488+
if (i === value.length - 1) return false
6489+
6490+
// A slash can only appear once. E.g.: `foo/bar/baz` is invalid
6491+
if (seenSlash) return false
6492+
seenSlash = true
6493+
break
6494+
}
6495+
6496+
case DOT: {
6497+
// Dots are only allowed between digits. E.g.: `p-1.a` is invalid
6498+
let previousChar = value[i - 1] || root[root.length - 1] || ''
6499+
let previousCharCode = previousChar.charCodeAt(0)
6500+
if (previousCharCode < ZERO || previousCharCode > NINE) return false
6501+
6502+
let nextChar = value[i + 1] || ''
6503+
let nextCharCode = nextChar.charCodeAt(0)
6504+
if (nextCharCode < ZERO || nextCharCode > NINE) return false
6505+
break
6506+
}
6507+
6508+
// Allowed special characters
6509+
case UNDERSCORE:
6510+
case DASH: {
6511+
continue
6512+
}
6513+
6514+
default: {
6515+
if (
6516+
(charCode >= LOWER_A && charCode <= LOWER_Z) || // Allow a-z
6517+
(charCode >= UPPER_A && charCode <= UPPER_Z) || // Allow A-Z
6518+
(charCode >= ZERO && charCode <= NINE) // Allow 0-9
6519+
) {
6520+
continue
6521+
}
6522+
6523+
// Everything else is invalid
6524+
return false
6525+
}
6526+
}
6527+
}
6528+
6529+
return true
6530+
}
6531+
6532+
export function isValidFunctionalUtilityName(name: string): boolean {
6533+
if (!name.endsWith('-*')) return false // Missing '-*' suffix
6534+
name = name.slice(0, -2)
6535+
6536+
let match = UTILITY_ROOT.exec(name)
6537+
if (match === null) return false // Invalid root
6538+
6539+
let root = match[0]
6540+
let value = name.slice(root.length)
6541+
6542+
// Root should not end in `-` if there is no value
6543+
//
6544+
// `tab-size--*`
6545+
// --------- Root
6546+
// -- Suffix
6547+
//
6548+
// Because with default values, this could match `tab-size-` which is invalid.
6549+
if (value.length === 0 && root.endsWith('-')) {
6550+
return false
6551+
}
6552+
6553+
// No remaining value is valid
6554+
//
6555+
// `tab-size-*`
6556+
// -------- Root
6557+
// -- Suffix
6558+
if (value.length === 0) {
6559+
return true
6560+
}
6561+
6562+
// But if there is a value remaining, it's invalid.
6563+
//
6564+
// E.g.: `tab-size-[…]-*`
6565+
//
6566+
// If we allow more characters, we can extend the validation here
6567+
return false
6568+
}

0 commit comments

Comments
 (0)