From 7ac7ff7b58203c6ef058f1c8856bcb3b2a9e7460 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 5 Jan 2026 15:10:43 +0100 Subject: [PATCH 1/5] add regression test --- packages/tailwindcss/src/index.test.ts | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 2d6ab8daf6f7..94b17714ed25 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -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 () => { From b30cb2e4448610b69bebafe0d316af0929373163 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 5 Jan 2026 15:11:02 +0100 Subject: [PATCH 2/5] =?UTF-8?q?add=20name=20validation=20test=20for=20`@ut?= =?UTF-8?q?ility=20=E2=80=A6`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/utilities.test.ts | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 732e4ab9a818..9a95590e4556 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -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,46 @@ 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', 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 + ])('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 { From a4776d03d00c24f2cb148ff4b5bb1f74960bd535 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 5 Jan 2026 15:11:17 +0100 Subject: [PATCH 3/5] add `@utility` name validation - We check for a valid root which should optionally start with `-`, and be followed by `[a-z]`. After that, only `[a-zA-Z0-9_-]` is valid for the root. - For static utilities, the remaining part (the value) can include: - `.`: but not consecutive ones, e.g. `foo..bar` is invalid - `%`: but only at the end and preceded by a digit, e.g. `foo-x%` and `foo-%-bar` are invalid - `/`: as the modifier, but there can only be one, and must be followed by another character, e.g.: `foo/bar/baz` and `foo/` are invalid - For functional utilities, we need a valid root and a valid `-*` suffix. The remaining "value" part should be empty. --- packages/tailwindcss/src/utilities.ts | 148 +++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 8ec903af64ec..e1c84ce00890 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -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 +} From 3c7683a9561b0095613e0cfcba85d89957350d5e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 5 Jan 2026 19:17:02 +0100 Subject: [PATCH 4/5] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5adb9f7f53d5..b66ebaea00dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 2a9a4f7778521ebcafe4cd279a40cbe7ddbee688 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 6 Jan 2026 12:48:33 +0100 Subject: [PATCH 5/5] add 2 more test cases These are the ones that represent the linked issue even though we have a dedicated regrression test for it. --- packages/tailwindcss/src/utilities.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 9a95590e4556..cff2284f45c9 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -27220,6 +27220,7 @@ describe('custom utilities', () => { ['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) @@ -27229,6 +27230,7 @@ describe('custom utilities', () => { ['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) })