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.
This commit is contained in:
philipp-spiess 2024-10-22 16:56:16 +00:00
parent fc261bdcf7
commit 816e7307de
8 changed files with 205 additions and 9 deletions

View File

@ -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))

View File

@ -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', () => {

View File

@ -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'

View File

@ -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', () => {

View File

@ -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
}

View File

@ -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<string, unknown> & { __CSS_VALUES__?: Record<string, number> } =
// 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

View File

@ -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"`)
})
})

View File

@ -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]
})
}