From 816e7307dee551668ddd3f6a27d4b092edda7829 Mon Sep 17 00:00:00 2001 From: philipp-spiess <458591+philipp-spiess@users.noreply.github.com> Date: Tue, 22 Oct 2024 16:56:16 +0000 Subject: [PATCH] Escape JS theme configuration keys (#14739) This PR fixes two issues related to how we tread JS theme keys in combination with CSS theme values: 1. When applying JS theme keys to our `Theme` class, we need to ensure they are escaped in the same way as reading CSS theme keys from CSS are. 2. When JS plugins use the `theme()` function to read a namespace that has values contributed to from the CSS theme and the JS theme, we need to ensure that the resulting set contains only unescaped theme keys. For specific examples, please take a look at the test cases. --- CHANGELOG.md | 2 + .../src/compat/apply-config-to-theme.test.ts | 10 ++ .../src/compat/apply-config-to-theme.ts | 7 +- .../tailwindcss/src/compat/plugin-api.test.ts | 159 ++++++++++++++++++ packages/tailwindcss/src/compat/plugin-api.ts | 10 +- .../src/compat/plugin-functions.ts | 4 +- packages/tailwindcss/src/utils/escape.test.ts | 14 ++ packages/tailwindcss/src/utils/escape.ts | 8 + 8 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 packages/tailwindcss/src/utils/escape.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd9bbd78808..900f23abeda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure color opacity modifiers work with OKLCH colors ([#14741](https://github.com/tailwindlabs/tailwindcss/pull/14741)) - Ensure changes to the input CSS file result in a full rebuild ([#14744](https://github.com/tailwindlabs/tailwindcss/pull/14744)) - Add `postcss` as a dependency of `@tailwindcss/postcss` ([#14750](https://github.com/tailwindlabs/tailwindcss/pull/14750)) +- Ensure the JS `theme()` function can reference CSS theme variables that contain special characters without escaping them (e.g. referencing `--width-1\/2` as `theme('width.1/2')`) ([#14739](https://github.com/tailwindlabs/tailwindcss/pull/14739)) +- Ensure JS theme keys containing special characters correctly produce utility classes (e.g. `'1/2': 50%` to `w-1/2`) ([#14739](https://github.com/tailwindlabs/tailwindcss/pull/14739)) - Always emit keyframes registered in `addUtilities` ([#14747](https://github.com/tailwindlabs/tailwindcss/pull/14747)) - Ensure loading stylesheets via the `?raw` and `?url` static asset query works when using the Vite plugin ([#14716](https://github.com/tailwindlabs/tailwindcss/pull/14716)) - _Upgrade (experimental)_: Migrate `flex-grow` to `grow` and `flex-shrink` to `shrink` ([#14721](https://github.com/tailwindlabs/tailwindcss/pull/14721)) diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts index 9b415e0d1a3c..4adbce09d75b 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts @@ -49,6 +49,13 @@ test('config values can be merged into the theme', () => { }, ], }, + + width: { + // Purposely setting to something different from the default + '1/2': '60%', + '0.5': '60%', + '100%': '100%', + }, }, }, base: '/root', @@ -73,6 +80,9 @@ test('config values can be merged into the theme', () => { '1rem', { '--line-height': '1.5' }, ]) + expect(theme.resolve('1/2', ['--width'])).toEqual('60%') + expect(theme.resolve('0.5', ['--width'])).toEqual('60%') + expect(theme.resolve('100%', ['--width'])).toEqual('100%') }) test('will reset default theme values with overwriting theme values', () => { diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index a8dd432539a8..2194f0ae18b0 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -1,5 +1,6 @@ import type { DesignSystem } from '../design-system' import { ThemeOptions } from '../theme' +import { escape } from '../utils/escape' import type { ResolvedConfig } from './config/types' function resolveThemeValue(value: unknown, subValue: string | null = null): string | null { @@ -40,8 +41,8 @@ export function applyConfigToTheme( if (!name) continue designSystem.theme.add( - `--${name}`, - value as any, + `--${escape(name)}`, + '' + value, ThemeOptions.INLINE | ThemeOptions.REFERENCE | ThemeOptions.DEFAULT, ) } @@ -124,7 +125,7 @@ export function themeableValues(config: ResolvedConfig['theme']): [string[], unk return toAdd } -const IS_VALID_KEY = /^[a-zA-Z0-9-_]+$/ +const IS_VALID_KEY = /^[a-zA-Z0-9-_%/\.]+$/ export function keyPathToCssProperty(path: string[]) { if (path[0] === 'colors') path[0] = 'color' diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 81789bec212e..9d5fea5911e2 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -1242,6 +1242,165 @@ describe('theme', async () => { " `) }) + + test('can use escaped JS variables in theme values', async () => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + ` + + let compiler = await compile(input, { + loadModule: async (id, base) => { + return { + base, + module: plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { 'my-width': (value) => ({ width: value }) }, + { values: theme('width') }, + ) + }, + { + theme: { + extend: { + width: { + '1': '0.25rem', + // Purposely setting to something different from the v3 default + '1/2': '60%', + '1.5': '0.375rem', + }, + }, + }, + }, + ), + } + }, + }) + + expect(compiler.build(['my-width-1', 'my-width-1/2', 'my-width-1.5'])).toMatchInlineSnapshot( + ` + ".my-width-1 { + width: 0.25rem; + } + .my-width-1\\.5 { + width: 0.375rem; + } + .my-width-1\\/2 { + width: 60%; + } + " + `, + ) + }) + + test('can use escaped CSS variables in theme values', async () => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + + @theme { + --width-1: 0.25rem; + /* Purposely setting to something different from the v3 default */ + --width-1\/2: 60%; + --width-1\.5: 0.375rem; + --width-2_5: 0.625rem; + } + ` + + let compiler = await compile(input, { + loadModule: async (id, base) => { + return { + base, + module: plugin(function ({ matchUtilities, theme }) { + matchUtilities( + { 'my-width': (value) => ({ width: value }) }, + { values: theme('width') }, + ) + }), + } + }, + }) + + expect(compiler.build(['my-width-1', 'my-width-1.5', 'my-width-1/2', 'my-width-2.5'])) + .toMatchInlineSnapshot(` + ".my-width-1 { + width: 0.25rem; + } + .my-width-1\\.5 { + width: 0.375rem; + } + .my-width-1\\/2 { + width: 60%; + } + .my-width-2\\.5 { + width: 0.625rem; + } + :root { + --width-1: 0.25rem; + --width-1\\/2: 60%; + --width-1\\.5: 0.375rem; + --width-2_5: 0.625rem; + } + " + `) + }) + + test('can use escaped CSS variables in referenced theme namespace', async () => { + let input = css` + @tailwind utilities; + @plugin "my-plugin"; + + @theme { + --width-1: 0.25rem; + /* Purposely setting to something different from the v3 default */ + --width-1\/2: 60%; + --width-1\.5: 0.375rem; + --width-2_5: 0.625rem; + } + ` + + let compiler = await compile(input, { + loadModule: async (id, base) => { + return { + base, + module: plugin( + function ({ matchUtilities, theme }) { + matchUtilities( + { 'my-width': (value) => ({ width: value }) }, + { values: theme('myWidth') }, + ) + }, + { + theme: { myWidth: ({ theme }) => theme('width') }, + }, + ), + } + }, + }) + + expect(compiler.build(['my-width-1', 'my-width-1.5', 'my-width-1/2', 'my-width-2.5'])) + .toMatchInlineSnapshot(` + ".my-width-1 { + width: 0.25rem; + } + .my-width-1\\.5 { + width: 0.375rem; + } + .my-width-1\\/2 { + width: 60%; + } + .my-width-2\\.5 { + width: 0.625rem; + } + :root { + --width-1: 0.25rem; + --width-1\\/2: 60%; + --width-1\\.5: 0.375rem; + --width-2_5: 0.625rem; + } + " + `) + }) }) describe('addVariant', () => { diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index a4552520d174..1e7cd9e197dd 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -267,7 +267,7 @@ export function buildPluginApi( // Resolve the candidate value let value: string | null = null - let isFraction = false + let ignoreModifier = false { let values = options?.values ?? {} @@ -289,12 +289,14 @@ export function buildPluginApi( value = values.DEFAULT ?? null } else if (candidate.value.kind === 'arbitrary') { value = candidate.value.value + } else if (candidate.value.fraction && values[candidate.value.fraction]) { + value = values[candidate.value.fraction] + ignoreModifier = true } else if (values[candidate.value.value]) { value = values[candidate.value.value] } else if (values.__BARE_VALUE__) { value = values.__BARE_VALUE__(candidate.value) ?? null - - isFraction = (candidate.value.fraction !== null && value?.includes('/')) ?? false + ignoreModifier = (candidate.value.fraction !== null && value?.includes('/')) ?? false } } @@ -320,7 +322,7 @@ export function buildPluginApi( } // A modifier was provided but is invalid - if (candidate.modifier && modifier === null && !isFraction) { + if (candidate.modifier && modifier === null && !ignoreModifier) { // For arbitrary values, return `null` to avoid falling through to the next utility return candidate.value?.kind === 'arbitrary' ? null : undefined } diff --git a/packages/tailwindcss/src/compat/plugin-functions.ts b/packages/tailwindcss/src/compat/plugin-functions.ts index b75815e4436f..711adc8c78ff 100644 --- a/packages/tailwindcss/src/compat/plugin-functions.ts +++ b/packages/tailwindcss/src/compat/plugin-functions.ts @@ -2,6 +2,7 @@ import type { DesignSystem } from '../design-system' import { ThemeOptions, type Theme, type ThemeKey } from '../theme' import { withAlpha } from '../utilities' import { DefaultMap } from '../utils/default-map' +import { unescape } from '../utils/escape' import { toKeyPath } from '../utils/to-key-path' import { deepMerge } from './config/deep-merge' import type { UserConfig } from './config/types' @@ -37,7 +38,6 @@ export function createThemeFn( return cssValue } - // if (configValue !== null && typeof configValue === 'object' && !Array.isArray(configValue)) { let configValueCopy: Record & { __CSS_VALUES__?: Record } = // We want to make sure that we don't mutate the original config @@ -70,7 +70,7 @@ export function createThemeFn( } // CSS values from `@theme` win over values from the config - configValueCopy[key] = cssValue[key] + configValueCopy[unescape(key)] = cssValue[key] } return configValueCopy diff --git a/packages/tailwindcss/src/utils/escape.test.ts b/packages/tailwindcss/src/utils/escape.test.ts new file mode 100644 index 000000000000..ff7715b9d8dc --- /dev/null +++ b/packages/tailwindcss/src/utils/escape.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'vitest' +import { escape, unescape } from './escape' + +describe('escape', () => { + test('adds backslashes', () => { + expect(escape(String.raw`red-1/2`)).toMatchInlineSnapshot(`"red-1\\/2"`) + }) +}) + +describe('unescape', () => { + test('removes backslashes', () => { + expect(unescape(String.raw`red-1\/2`)).toMatchInlineSnapshot(`"red-1/2"`) + }) +}) diff --git a/packages/tailwindcss/src/utils/escape.ts b/packages/tailwindcss/src/utils/escape.ts index da45fb944060..246c59df2b6e 100644 --- a/packages/tailwindcss/src/utils/escape.ts +++ b/packages/tailwindcss/src/utils/escape.ts @@ -71,3 +71,11 @@ export function escape(value: string) { } return result } + +export function unescape(escaped: string) { + return escaped.replace(/\\([\dA-Fa-f]{1,6}[\t\n\f\r ]?|[\S\s])/g, (match) => { + return match.length > 2 + ? String.fromCodePoint(Number.parseInt(match.slice(1).trim(), 16)) + : match[1] + }) +}