mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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:
parent
fc261bdcf7
commit
816e7307de
@ -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))
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
14
packages/tailwindcss/src/utils/escape.test.ts
Normal file
14
packages/tailwindcss/src/utils/escape.test.ts
Normal 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"`)
|
||||
})
|
||||
})
|
||||
@ -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]
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user