diff --git a/CHANGELOG.md b/CHANGELOG.md index 40baa4c42bbd..bfe8bbeb97f8 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 - Make sure `contain-*` utility variables resolve to a valid value ([#13521](https://github.com/tailwindlabs/tailwindcss/pull/13521)) - Support unbalanced parentheses and braces in quotes in arbitrary values and variants ([#13608](https://github.com/tailwindlabs/tailwindcss/pull/13608)) +- Keep underscores in dashed-idents ([#13538](https://github.com/tailwindlabs/tailwindcss/pull/13538)) ### Changed diff --git a/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts b/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts index e78a25f7418d..1890465809ee 100644 --- a/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts +++ b/packages/tailwindcss/src/utils/decode-arbitrary-value.test.ts @@ -14,13 +14,40 @@ describe('decoding arbitrary values', () => { expect(decodeArbitraryValue('foo\\_bar')).toBe('foo_bar') }) - it('should not replace underscores in url()', () => { + it('should not replace underscores in url(…)', () => { expect(decodeArbitraryValue('url(./my_file.jpg)')).toBe('url(./my_file.jpg)') }) - it('should leave var(…) as is', () => { - expect(decodeArbitraryValue('var(--foo)')).toBe('var(--foo)') - expect(decodeArbitraryValue('var(--headings-h1-size)')).toBe('var(--headings-h1-size)') + it('should not replace underscores in var(…)', () => { + expect(decodeArbitraryValue('var(--foo_bar)')).toBe('var(--foo_bar)') + }) + + it('should replace underscores in the fallback value of var(…)', () => { + expect(decodeArbitraryValue('var(--foo_bar, "my_content")')).toBe( + 'var(--foo_bar, "my content")', + ) + }) + + it('should not replace underscores in nested var(…)', () => { + expect(decodeArbitraryValue('var(--foo_bar, var(--bar_baz))')).toBe( + 'var(--foo_bar, var(--bar_baz))', + ) + }) + + it('should replace underscores in the fallback value of nested var(…)', () => { + expect(decodeArbitraryValue('var(--foo_bar, var(--bar_baz, "my_content"))')).toBe( + 'var(--foo_bar, var(--bar_baz, "my content"))', + ) + }) + + it('should not replace underscores in dashed idents', () => { + expect(decodeArbitraryValue('--foo_bar')).toBe('--foo_bar') + }) + + it('should replace underscores in strings that look like dashed idents', () => { + expect(decodeArbitraryValue('content-["some--thing_here"]')).toBe( + 'content-["some--thing here"]', + ) }) }) diff --git a/packages/tailwindcss/src/utils/decode-arbitrary-value.ts b/packages/tailwindcss/src/utils/decode-arbitrary-value.ts index f644e663995a..e1f6d708fc82 100644 --- a/packages/tailwindcss/src/utils/decode-arbitrary-value.ts +++ b/packages/tailwindcss/src/utils/decode-arbitrary-value.ts @@ -1,5 +1,17 @@ import { addWhitespaceAroundMathOperators } from './math-operators' +const BACKSLASH = 0x5c +const UNDERSCORE = 0x5f +const DASH = 0x2d +const DOUBLE_QUOTE = 0x22 +const SINGLE_QUOTE = 0x27 +const LOWER_A = 0x61 +const LOWER_Z = 0x7a +const UPPER_A = 0x41 +const UPPER_Z = 0x5a +const ZERO = 0x30 +const NINE = 0x39 + export function decodeArbitraryValue(input: string): string { // We do not want to normalize anything inside of a url() because if we // replace `_` with ` `, then it will very likely break the url. @@ -14,28 +26,126 @@ export function decodeArbitraryValue(input: string): string { } /** - * Convert `_` to ` `, except for escaped underscores `\_` they should be - * converted to `_` instead. + * Convert underscores `_` to whitespace ` ` + * + * Except for: + * + * - Escaped underscores `\_`, these are converted to underscores `_` + * - Dashed idents `--foo_bar`, these are left as-is + * + * Inside strings, dashed idents are considered to be normal strings without any + * special meaning, so the `_` in "dashed idents" are converted to whitespace. */ function convertUnderscoresToWhitespace(input: string) { let output = '' - for (let i = 0; i < input.length; i++) { - let char = input[i] + let len = input.length + + for (let idx = 0; idx < len; idx++) { + let char = input.charCodeAt(idx) + + // Escaped values + if (input.charCodeAt(idx) === BACKSLASH) { + // An escaped underscore (e.g.: `\_`) is converted to a non-escaped + // underscore, but without converting the `_` to a space. + if (input.charCodeAt(idx + 1) === UNDERSCORE) { + output += '_' + idx += 1 + } - // Escaped underscore - if (char === '\\' && input[i + 1] === '_') { - output += '_' - i += 1 + // Consume the backslash and the next character as-is + else { + output += input.slice(idx, idx + 2) + idx += 1 + } } - // Unescaped underscore - else if (char === '_') { + // Underscores are converted to whitespace + else if (char === UNDERSCORE) { output += ' ' } + // Start of a dashed ident, consume the ident as-is + else if (char === DASH && input.charCodeAt(idx + 1) === DASH) { + let start = idx + + // Skip the first two dashes, we already know they are there + idx += 2 + + char = input.charCodeAt(idx) + while ( + (char >= LOWER_A && char <= LOWER_Z) || + (char >= UPPER_A && char <= UPPER_Z) || + (char >= ZERO && char <= NINE) || + char === DASH || + char === UNDERSCORE || + char === BACKSLASH + ) { + // Escaped value, consume the next character as-is + if (char === BACKSLASH) { + // In theory, we can also escape a unicode code point where 1 to 6 hex + // digits are allowed after the \. However, each hex digit is also a + // valid ident character, so we can just consume the next character + // as-is and go to the next character. + idx += 1 + } + + // Next character + char = input.charCodeAt(++idx) + } + + output += input.slice(start, idx) + + // The last character was not a valid ident character, so we need to back + // up one character. + idx -= 1 + } + + // Start of a string + else if (char === SINGLE_QUOTE || char === DOUBLE_QUOTE) { + let quote = input[idx++] + + // Keep the quote + output += quote + + // Consume to the end of the string, but replace any non-escaped + // underscores with spaces. + while (idx < len && input.charCodeAt(idx) !== char) { + // Escaped values + if (input.charCodeAt(idx) === BACKSLASH) { + // An escaped underscore (e.g.: `\_`) is converted to a non-escaped + // underscore, but without converting the `_` to a space. + if (input.charCodeAt(idx + 1) === UNDERSCORE) { + output += '_' + idx += 1 + } + + // Consume the backslash and the next character as-is + else { + output += input.slice(idx, idx + 2) + idx += 1 + } + } + + // Unescaped underscore + else if (input.charCodeAt(idx) === UNDERSCORE) { + output += ' ' + } + + // All other characters + else { + output += input[idx] + } + + idx += 1 + } + + // Keep the end quote + output += quote + } + // All other characters else { - output += char + output += input[idx] } }