diff --git a/CHANGELOG.md b/CHANGELOG.md index 515473f78..a1f5644a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,15 @@ 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)) - 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)) - _Upgrade (experimental)_: Minify arbitrary values when printing candidates ([#14720](https://github.com/tailwindlabs/tailwindcss/pull/14720)) - _Upgrade (experimental)_: Ensure legacy theme values ending in `1` (like `theme(spacing.1)`) are correctly migrated to custom properties ([#14724](https://github.com/tailwindlabs/tailwindcss/pull/14724)) - _Upgrade (experimental)_: Migrate arbitrary values to bare values for the `from-*`, `via-*`, and `to-*` utilities ([#14725](https://github.com/tailwindlabs/tailwindcss/pull/14725)) - _Upgrade (experimental)_: Ensure `layer(utilities)` is removed from `@import` to keep `@utility` top-level ([#14738](https://github.com/tailwindlabs/tailwindcss/pull/14738)) +- _Upgrade (experimental)_: Ensure JS theme keys with special characters are escaped when migrated to CSS variables ([#14736](https://github.com/tailwindlabs/tailwindcss/pull/14736)) - _Upgrade (experimental)_: Don't migrate important modifiers that are actually logical negations (e.g. `let foo = !border` to `let foo = border!`) ([#14737](https://github.com/tailwindlabs/tailwindcss/pull/14737)) ### Changed diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 22e4ace9c..f6f7981cf 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -34,6 +34,57 @@ test( sm: ['0.875rem', { lineHeight: '1.5rem' }], base: ['1rem', { lineHeight: '2rem' }], }, + width: { + px: '1px', + auto: 'auto', + 1: '0.25rem', + 1.5: '0.375rem', + 2: '0.5rem', + 2.5: '0.625rem', + 3: '0.75rem', + 3.5: '0.875rem', + 4: '1rem', + 5: '1.25rem', + 6: '1.5rem', + 8: '2rem', + 10: '2.5rem', + 11: '2.75rem', + 12: '3rem', + 16: '4rem', + 24: '6rem', + 32: '8rem', + 40: '10rem', + 48: '12rem', + 64: '16rem', + 80: '20rem', + 96: '24rem', + 128: '32rem', + + full: '100%', + 0: '0%', + '1/2': '50%', + '1/3': 'calc(100% / 3)', + '2/3': 'calc(100% / 3 * 2)', + '1/4': '25%', + '3/4': '75%', + '1/5': '20%', + '2/5': '40%', + '3/5': '60%', + '4/5': '80%', + '1/6': 'calc(100% / 6)', + '5/6': 'calc(100% / 6 * 5)', + '1/7': 'calc(100% / 7)', + '1/10': 'calc(100% / 10)', + '3/10': 'calc(100% / 10 * 3)', + '7/10': 'calc(100% / 10 * 7)', + '9/10': 'calc(100% / 10 * 9)', + screen: '100vw', + + 'full-minus-80': 'calc(100% - 20rem)', + 'full-minus-96': 'calc(100% - 24rem)', + + '225px': '225px', + }, extend: { colors: { red: { @@ -113,6 +164,54 @@ test( --font-size-base: 1rem; --font-size-base--line-height: 2rem; + --width-*: initial; + --width-0: 0%; + --width-1: 0.25rem; + --width-2: 0.5rem; + --width-3: 0.75rem; + --width-4: 1rem; + --width-5: 1.25rem; + --width-6: 1.5rem; + --width-8: 2rem; + --width-10: 2.5rem; + --width-11: 2.75rem; + --width-12: 3rem; + --width-16: 4rem; + --width-24: 6rem; + --width-32: 8rem; + --width-40: 10rem; + --width-48: 12rem; + --width-64: 16rem; + --width-80: 20rem; + --width-96: 24rem; + --width-128: 32rem; + --width-px: 1px; + --width-auto: auto; + --width-1_5: 0.375rem; + --width-2_5: 0.625rem; + --width-3_5: 0.875rem; + --width-full: 100%; + --width-1\\/2: 50%; + --width-1\\/3: calc(100% / 3); + --width-2\\/3: calc(100% / 3 * 2); + --width-1\\/4: 25%; + --width-3\\/4: 75%; + --width-1\\/5: 20%; + --width-2\\/5: 40%; + --width-3\\/5: 60%; + --width-4\\/5: 80%; + --width-1\\/6: calc(100% / 6); + --width-5\\/6: calc(100% / 6 * 5); + --width-1\\/7: calc(100% / 7); + --width-1\\/10: calc(100% / 10); + --width-3\\/10: calc(100% / 10 * 3); + --width-7\\/10: calc(100% / 10 * 7); + --width-9\\/10: calc(100% / 10 * 9); + --width-screen: 100vw; + --width-full-minus-80: calc(100% - 20rem); + --width-full-minus-96: calc(100% - 24rem); + --width-225px: 225px; + --font-family-sans: Inter, system-ui, sans-serif; --font-family-display: Cabinet Grotesk, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; diff --git a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts index 20f7fdd7f..f8a6ad6f8 100644 --- a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts @@ -15,6 +15,7 @@ import { resolveConfig, type ConfigFile } from '../../tailwindcss/src/compat/con import type { ThemeConfig } from '../../tailwindcss/src/compat/config/types' import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode' import type { DesignSystem } from '../../tailwindcss/src/design-system' +import { escape } from '../../tailwindcss/src/utils/escape' import { findStaticPlugins, type StaticPluginOptions } from './utils/extract-static-plugins' import { info } from './utils/renderer' @@ -121,10 +122,16 @@ async function migrateTheme( if (resetNamespaces.has(key[0]) && resetNamespaces.get(key[0]) === false) { resetNamespaces.set(key[0], true) - css += ` --${keyPathToCssProperty([key[0]])}-*: initial;\n` + let property = keyPathToCssProperty([key[0]]) + if (property !== null) { + css += ` ${escape(`--${property}`)}-*: initial;\n` + } } - css += ` --${keyPathToCssProperty(key)}: ${value};\n` + let property = keyPathToCssProperty(key) + if (property !== null) { + css += ` ${escape(`--${property}`)}: ${value};\n` + } } if ('keyframes' in resolvedConfig.theme) { 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 9b415e0d1..9cb5782f4 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts @@ -1,7 +1,7 @@ -import { expect, test } from 'vitest' +import { describe, expect, test } from 'vitest' import { buildDesignSystem } from '../design-system' import { Theme, ThemeOptions } from '../theme' -import { applyConfigToTheme } from './apply-config-to-theme' +import { applyConfigToTheme, keyPathToCssProperty } from './apply-config-to-theme' import { resolveConfig } from './config/resolve-config' test('config values can be merged into the theme', () => { @@ -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', () => { @@ -147,3 +157,12 @@ test('invalid keys are not merged into the theme', () => { expect(entries.length).toEqual(0) }) + +describe('keyPathToCssProperty', () => { + test.each([ + [['width', '40', '2/5'], '--width-40-2/5'], + [['spacing', '0.5'], '--spacing-0_5'], + ])('converts %s to %s', (keyPath, expected) => { + expect(`--${keyPathToCssProperty(keyPath)}`).toEqual(expected) + }) +}) diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index a8dd43253..2194f0ae1 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 abde27c9f..2c6bec07c 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -1209,6 +1209,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 8b9fc3bf8..79528b260 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 b75815e44..711adc8c7 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 000000000..ff7715b9d --- /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 da45fb944..246c59df2 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] + }) +}