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"
/>
This commit is contained in:
parent
dc432aaa18
commit
f82ac39ff2
4 changed files with 222 additions and 5 deletions
|
|
@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- CLI: Emit comment when source maps are saved to files ([#19447](https://github.com/tailwindlabs/tailwindcss/pull/19447))
|
||||
- Detect utilities when containing capital letters followed by numbers ([#19465](https://github.com/tailwindlabs/tailwindcss/pull/19465))
|
||||
- Fix class extraction for Rails' strict locals ([#19525](https://github.com/tailwindlabs/tailwindcss/pull/19525))
|
||||
- Align `@utility` name validation with Oxide scanner rules ([#19524](https://github.com/tailwindlabs/tailwindcss/pull/19524))
|
||||
|
||||
### Added
|
||||
|
||||
|
|
|
|||
|
|
@ -4660,6 +4660,41 @@ describe('@utility', () => {
|
|||
`[Error: \`@utility my-*-utility\` defines an invalid utility name. The dynamic portion marked by \`-*\` must appear once at the end.]`,
|
||||
)
|
||||
})
|
||||
|
||||
// https://github.com/tailwindlabs/tailwindcss/issues/19505
|
||||
test('@utility name cannot contain multiple `/` characters', async () => {
|
||||
await expect(
|
||||
compileCss(
|
||||
css`
|
||||
@utility ui/button {
|
||||
display: inline-flex;
|
||||
background: blue;
|
||||
}
|
||||
@tailwind utilities;
|
||||
`,
|
||||
['ui/button'],
|
||||
),
|
||||
).resolves.toMatchInlineSnapshot(
|
||||
`
|
||||
".ui\\/button {
|
||||
background: #00f;
|
||||
display: inline-flex;
|
||||
}"
|
||||
`,
|
||||
)
|
||||
|
||||
await expect(
|
||||
compileCss(css`
|
||||
@utility ui/button/sm {
|
||||
display: inline-flex;
|
||||
background: blue;
|
||||
font-size: 12px;
|
||||
}
|
||||
`),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: \`@utility ui/button/sm\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.]`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('addBase', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { compile } from '.'
|
||||
import { compileCss, optimizeCss, run } from './test-utils/run'
|
||||
import { isValidFunctionalUtilityName, isValidStaticUtilityName } from './utilities'
|
||||
|
||||
const css = String.raw
|
||||
|
||||
|
|
@ -27207,6 +27208,48 @@ describe('spacing utilities', () => {
|
|||
})
|
||||
|
||||
describe('custom utilities', () => {
|
||||
test.each([
|
||||
['foo', true], // Simple name
|
||||
['foo-123', true], // Ending with a number is valid
|
||||
['foo-2.5', true], // Dots are valid when surrounded by numbers
|
||||
['-foo', true], // Simple name with negative sign
|
||||
['foo-bar', true], // With dashes
|
||||
['foo_bar', true], // With underscores
|
||||
['foo-50%', true], // Bare value with percentage
|
||||
['foo-1/2', true], // Bare value with fraction
|
||||
['foo-sm/8', true], // Bare value with number modifier
|
||||
['foo-4/snug', true], // Bare value with named modifier
|
||||
['foo_', true], // This is supported today, so let's not break it
|
||||
['foo/bar', true], // A slash to separate the modifier is valid.
|
||||
|
||||
['Foo', false], // Starting with uppercase letter is invalid
|
||||
['-Foo', false], // Starting with uppercase letter is invalid (negative)
|
||||
['foo-', false], // Should not end with a dash
|
||||
['foo-1/', false], // Invalid fraction/modifier
|
||||
['foo-p%', false], // Invalid percentage
|
||||
['foo.bar', false], // Dots are only valid when surrounded by numbers
|
||||
['foo-1..5', false], // Double dots are invalid
|
||||
['foo..bar', false], // Double dots are invalid definitely without numbers
|
||||
['foo/bar/baz', false], // Multiple slashes are invalid
|
||||
])('valid static utility name "%s" (%s)', (name, valid) => {
|
||||
expect(isValidStaticUtilityName(name)).toBe(valid)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['foo', false], // Simple name, missing '-*' suffix
|
||||
['foo-*', true], // Simple name
|
||||
['foo--*', false], // Root should not end in `-`
|
||||
['-foo-*', true], // Simple name (negative)
|
||||
['foo-bar-*', true], // With dashes
|
||||
['foo_bar-*', true], // With underscores
|
||||
['Foo-*', false], // Starting with uppercase letter is invalid
|
||||
['-Foo-*', false], // Starting with uppercase letter is invalid
|
||||
['foo!-*', false], // Invalid special character
|
||||
['foo-[…]', false], // Invalid special character
|
||||
])('valid functional name "%s" (%s)', (name, valid) => {
|
||||
expect(isValidFunctionalUtilityName(name)).toBe(valid)
|
||||
})
|
||||
|
||||
test('custom static utility', async () => {
|
||||
let { build } = await compile(css`
|
||||
@layer utilities {
|
||||
|
|
|
|||
|
|
@ -28,9 +28,6 @@ import { segment } from './utils/segment'
|
|||
import * as ValueParser from './value-parser'
|
||||
import { walk, WalkAction } from './walk'
|
||||
|
||||
const IS_VALID_STATIC_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*$/
|
||||
const IS_VALID_FUNCTIONAL_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*-\*$/
|
||||
|
||||
const DEFAULT_SPACING_SUGGESTIONS = [
|
||||
'0',
|
||||
'0.5',
|
||||
|
|
@ -5835,7 +5832,7 @@ export function createCssUtility(node: AtRule) {
|
|||
let name = node.params
|
||||
|
||||
// Functional utilities. E.g.: `tab-size-*`
|
||||
if (IS_VALID_FUNCTIONAL_UTILITY_NAME.test(name)) {
|
||||
if (isValidFunctionalUtilityName(name)) {
|
||||
// API:
|
||||
//
|
||||
// - `--value('literal')` resolves a literal named value
|
||||
|
|
@ -6184,7 +6181,7 @@ export function createCssUtility(node: AtRule) {
|
|||
}
|
||||
}
|
||||
|
||||
if (IS_VALID_STATIC_UTILITY_NAME.test(name)) {
|
||||
if (isValidStaticUtilityName(name)) {
|
||||
return (designSystem: DesignSystem) => {
|
||||
designSystem.utilities.static(name, () => node.nodes.map(cloneAstNode))
|
||||
}
|
||||
|
|
@ -6428,3 +6425,144 @@ function alphaReplacedDropShadowProperties(
|
|||
return [decl(property, prefix + replacedValue)]
|
||||
}
|
||||
}
|
||||
|
||||
const UTILITY_ROOT = /^-?[a-z][a-zA-Z0-9_-]*/
|
||||
|
||||
const PERCENT = 37
|
||||
const SLASH = 47
|
||||
const DOT = 46
|
||||
const LOWER_A = 97
|
||||
const LOWER_Z = 122
|
||||
const UPPER_A = 65
|
||||
const UPPER_Z = 90
|
||||
const ZERO = 48
|
||||
const NINE = 57
|
||||
const UNDERSCORE = 95
|
||||
const DASH = 45
|
||||
|
||||
export function isValidStaticUtilityName(name: string): boolean {
|
||||
let match = UTILITY_ROOT.exec(name)
|
||||
if (match === null) return false // Invalid root
|
||||
|
||||
let root = match[0]
|
||||
let value = name.slice(root.length)
|
||||
|
||||
// Root should not end in `-` if there is no value
|
||||
//
|
||||
// `tab-size-`
|
||||
// --------- Root
|
||||
if (value.length === 0 && root.endsWith('-')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// No remaining value is valid
|
||||
//
|
||||
// `tab-size`
|
||||
// -------- Root
|
||||
if (value.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Any valid (static) utility should be valid including:
|
||||
// - Bare values with `.`: `p-1.5`
|
||||
// - Bare values with `%`: `w-50%`
|
||||
// - With an embedded modifier: `text-xs/8`
|
||||
|
||||
let seenSlash = false
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
let charCode = value.charCodeAt(i)
|
||||
switch (charCode) {
|
||||
case PERCENT: {
|
||||
// A percentage is only valid at the end of the value
|
||||
if (i !== value.length - 1) return false
|
||||
|
||||
// A percent is only valid when preceded by a digit. E.g.: `w-%` is invalid
|
||||
let previousChar = value[i - 1] || root[root.length - 1] || ''
|
||||
let previousCharCode = previousChar.charCodeAt(0)
|
||||
if (previousCharCode < ZERO || previousCharCode > NINE) return false
|
||||
break
|
||||
}
|
||||
|
||||
case SLASH: {
|
||||
// A slash must be followed by at least 1 character. E.g.: `foo/` is invalid
|
||||
if (i === value.length - 1) return false
|
||||
|
||||
// A slash can only appear once. E.g.: `foo/bar/baz` is invalid
|
||||
if (seenSlash) return false
|
||||
seenSlash = true
|
||||
break
|
||||
}
|
||||
|
||||
case DOT: {
|
||||
// Dots are only allowed between digits. E.g.: `p-1.a` is invalid
|
||||
let previousChar = value[i - 1] || root[root.length - 1] || ''
|
||||
let previousCharCode = previousChar.charCodeAt(0)
|
||||
if (previousCharCode < ZERO || previousCharCode > NINE) return false
|
||||
|
||||
let nextChar = value[i + 1] || ''
|
||||
let nextCharCode = nextChar.charCodeAt(0)
|
||||
if (nextCharCode < ZERO || nextCharCode > NINE) return false
|
||||
break
|
||||
}
|
||||
|
||||
// Allowed special characters
|
||||
case UNDERSCORE:
|
||||
case DASH: {
|
||||
continue
|
||||
}
|
||||
|
||||
default: {
|
||||
if (
|
||||
(charCode >= LOWER_A && charCode <= LOWER_Z) || // Allow a-z
|
||||
(charCode >= UPPER_A && charCode <= UPPER_Z) || // Allow A-Z
|
||||
(charCode >= ZERO && charCode <= NINE) // Allow 0-9
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else is invalid
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function isValidFunctionalUtilityName(name: string): boolean {
|
||||
if (!name.endsWith('-*')) return false // Missing '-*' suffix
|
||||
name = name.slice(0, -2)
|
||||
|
||||
let match = UTILITY_ROOT.exec(name)
|
||||
if (match === null) return false // Invalid root
|
||||
|
||||
let root = match[0]
|
||||
let value = name.slice(root.length)
|
||||
|
||||
// Root should not end in `-` if there is no value
|
||||
//
|
||||
// `tab-size--*`
|
||||
// --------- Root
|
||||
// -- Suffix
|
||||
//
|
||||
// Because with default values, this could match `tab-size-` which is invalid.
|
||||
if (value.length === 0 && root.endsWith('-')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// No remaining value is valid
|
||||
//
|
||||
// `tab-size-*`
|
||||
// -------- Root
|
||||
// -- Suffix
|
||||
if (value.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// But if there is a value remaining, it's invalid.
|
||||
//
|
||||
// E.g.: `tab-size-[…]-*`
|
||||
//
|
||||
// If we allow more characters, we can extend the validation here
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue