Skip to content

Commit 816e730

Browse files
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.
1 parent fc261bd commit 816e730

File tree

8 files changed

+205
-9
lines changed

8 files changed

+205
-9
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Ensure color opacity modifiers work with OKLCH colors ([#14741](https://github.com/tailwindlabs/tailwindcss/pull/14741))
2222
- Ensure changes to the input CSS file result in a full rebuild ([#14744](https://github.com/tailwindlabs/tailwindcss/pull/14744))
2323
- Add `postcss` as a dependency of `@tailwindcss/postcss` ([#14750](https://github.com/tailwindlabs/tailwindcss/pull/14750))
24+
- 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))
25+
- 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))
2426
- Always emit keyframes registered in `addUtilities` ([#14747](https://github.com/tailwindlabs/tailwindcss/pull/14747))
2527
- 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))
2628
- _Upgrade (experimental)_: Migrate `flex-grow` to `grow` and `flex-shrink` to `shrink` ([#14721](https://github.com/tailwindlabs/tailwindcss/pull/14721))

packages/tailwindcss/src/compat/apply-config-to-theme.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ test('config values can be merged into the theme', () => {
4949
},
5050
],
5151
},
52+
53+
width: {
54+
// Purposely setting to something different from the default
55+
'1/2': '60%',
56+
'0.5': '60%',
57+
'100%': '100%',
58+
},
5259
},
5360
},
5461
base: '/root',
@@ -73,6 +80,9 @@ test('config values can be merged into the theme', () => {
7380
'1rem',
7481
{ '--line-height': '1.5' },
7582
])
83+
expect(theme.resolve('1/2', ['--width'])).toEqual('60%')
84+
expect(theme.resolve('0.5', ['--width'])).toEqual('60%')
85+
expect(theme.resolve('100%', ['--width'])).toEqual('100%')
7686
})
7787

7888
test('will reset default theme values with overwriting theme values', () => {

packages/tailwindcss/src/compat/apply-config-to-theme.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DesignSystem } from '../design-system'
22
import { ThemeOptions } from '../theme'
3+
import { escape } from '../utils/escape'
34
import type { ResolvedConfig } from './config/types'
45

56
function resolveThemeValue(value: unknown, subValue: string | null = null): string | null {
@@ -40,8 +41,8 @@ export function applyConfigToTheme(
4041
if (!name) continue
4142

4243
designSystem.theme.add(
43-
`--${name}`,
44-
value as any,
44+
`--${escape(name)}`,
45+
'' + value,
4546
ThemeOptions.INLINE | ThemeOptions.REFERENCE | ThemeOptions.DEFAULT,
4647
)
4748
}
@@ -124,7 +125,7 @@ export function themeableValues(config: ResolvedConfig['theme']): [string[], unk
124125
return toAdd
125126
}
126127

127-
const IS_VALID_KEY = /^[a-zA-Z0-9-_]+$/
128+
const IS_VALID_KEY = /^[a-zA-Z0-9-_%/\.]+$/
128129

129130
export function keyPathToCssProperty(path: string[]) {
130131
if (path[0] === 'colors') path[0] = 'color'

packages/tailwindcss/src/compat/plugin-api.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,6 +1242,165 @@ describe('theme', async () => {
12421242
"
12431243
`)
12441244
})
1245+
1246+
test('can use escaped JS variables in theme values', async () => {
1247+
let input = css`
1248+
@tailwind utilities;
1249+
@plugin "my-plugin";
1250+
`
1251+
1252+
let compiler = await compile(input, {
1253+
loadModule: async (id, base) => {
1254+
return {
1255+
base,
1256+
module: plugin(
1257+
function ({ matchUtilities, theme }) {
1258+
matchUtilities(
1259+
{ 'my-width': (value) => ({ width: value }) },
1260+
{ values: theme('width') },
1261+
)
1262+
},
1263+
{
1264+
theme: {
1265+
extend: {
1266+
width: {
1267+
'1': '0.25rem',
1268+
// Purposely setting to something different from the v3 default
1269+
'1/2': '60%',
1270+
'1.5': '0.375rem',
1271+
},
1272+
},
1273+
},
1274+
},
1275+
),
1276+
}
1277+
},
1278+
})
1279+
1280+
expect(compiler.build(['my-width-1', 'my-width-1/2', 'my-width-1.5'])).toMatchInlineSnapshot(
1281+
`
1282+
".my-width-1 {
1283+
width: 0.25rem;
1284+
}
1285+
.my-width-1\\.5 {
1286+
width: 0.375rem;
1287+
}
1288+
.my-width-1\\/2 {
1289+
width: 60%;
1290+
}
1291+
"
1292+
`,
1293+
)
1294+
})
1295+
1296+
test('can use escaped CSS variables in theme values', async () => {
1297+
let input = css`
1298+
@tailwind utilities;
1299+
@plugin "my-plugin";
1300+
1301+
@theme {
1302+
--width-1: 0.25rem;
1303+
/* Purposely setting to something different from the v3 default */
1304+
--width-1\/2: 60%;
1305+
--width-1\.5: 0.375rem;
1306+
--width-2_5: 0.625rem;
1307+
}
1308+
`
1309+
1310+
let compiler = await compile(input, {
1311+
loadModule: async (id, base) => {
1312+
return {
1313+
base,
1314+
module: plugin(function ({ matchUtilities, theme }) {
1315+
matchUtilities(
1316+
{ 'my-width': (value) => ({ width: value }) },
1317+
{ values: theme('width') },
1318+
)
1319+
}),
1320+
}
1321+
},
1322+
})
1323+
1324+
expect(compiler.build(['my-width-1', 'my-width-1.5', 'my-width-1/2', 'my-width-2.5']))
1325+
.toMatchInlineSnapshot(`
1326+
".my-width-1 {
1327+
width: 0.25rem;
1328+
}
1329+
.my-width-1\\.5 {
1330+
width: 0.375rem;
1331+
}
1332+
.my-width-1\\/2 {
1333+
width: 60%;
1334+
}
1335+
.my-width-2\\.5 {
1336+
width: 0.625rem;
1337+
}
1338+
:root {
1339+
--width-1: 0.25rem;
1340+
--width-1\\/2: 60%;
1341+
--width-1\\.5: 0.375rem;
1342+
--width-2_5: 0.625rem;
1343+
}
1344+
"
1345+
`)
1346+
})
1347+
1348+
test('can use escaped CSS variables in referenced theme namespace', async () => {
1349+
let input = css`
1350+
@tailwind utilities;
1351+
@plugin "my-plugin";
1352+
1353+
@theme {
1354+
--width-1: 0.25rem;
1355+
/* Purposely setting to something different from the v3 default */
1356+
--width-1\/2: 60%;
1357+
--width-1\.5: 0.375rem;
1358+
--width-2_5: 0.625rem;
1359+
}
1360+
`
1361+
1362+
let compiler = await compile(input, {
1363+
loadModule: async (id, base) => {
1364+
return {
1365+
base,
1366+
module: plugin(
1367+
function ({ matchUtilities, theme }) {
1368+
matchUtilities(
1369+
{ 'my-width': (value) => ({ width: value }) },
1370+
{ values: theme('myWidth') },
1371+
)
1372+
},
1373+
{
1374+
theme: { myWidth: ({ theme }) => theme('width') },
1375+
},
1376+
),
1377+
}
1378+
},
1379+
})
1380+
1381+
expect(compiler.build(['my-width-1', 'my-width-1.5', 'my-width-1/2', 'my-width-2.5']))
1382+
.toMatchInlineSnapshot(`
1383+
".my-width-1 {
1384+
width: 0.25rem;
1385+
}
1386+
.my-width-1\\.5 {
1387+
width: 0.375rem;
1388+
}
1389+
.my-width-1\\/2 {
1390+
width: 60%;
1391+
}
1392+
.my-width-2\\.5 {
1393+
width: 0.625rem;
1394+
}
1395+
:root {
1396+
--width-1: 0.25rem;
1397+
--width-1\\/2: 60%;
1398+
--width-1\\.5: 0.375rem;
1399+
--width-2_5: 0.625rem;
1400+
}
1401+
"
1402+
`)
1403+
})
12451404
})
12461405

12471406
describe('addVariant', () => {

packages/tailwindcss/src/compat/plugin-api.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ export function buildPluginApi(
267267

268268
// Resolve the candidate value
269269
let value: string | null = null
270-
let isFraction = false
270+
let ignoreModifier = false
271271

272272
{
273273
let values = options?.values ?? {}
@@ -289,12 +289,14 @@ export function buildPluginApi(
289289
value = values.DEFAULT ?? null
290290
} else if (candidate.value.kind === 'arbitrary') {
291291
value = candidate.value.value
292+
} else if (candidate.value.fraction && values[candidate.value.fraction]) {
293+
value = values[candidate.value.fraction]
294+
ignoreModifier = true
292295
} else if (values[candidate.value.value]) {
293296
value = values[candidate.value.value]
294297
} else if (values.__BARE_VALUE__) {
295298
value = values.__BARE_VALUE__(candidate.value) ?? null
296-
297-
isFraction = (candidate.value.fraction !== null && value?.includes('/')) ?? false
299+
ignoreModifier = (candidate.value.fraction !== null && value?.includes('/')) ?? false
298300
}
299301
}
300302

@@ -320,7 +322,7 @@ export function buildPluginApi(
320322
}
321323

322324
// A modifier was provided but is invalid
323-
if (candidate.modifier && modifier === null && !isFraction) {
325+
if (candidate.modifier && modifier === null && !ignoreModifier) {
324326
// For arbitrary values, return `null` to avoid falling through to the next utility
325327
return candidate.value?.kind === 'arbitrary' ? null : undefined
326328
}

packages/tailwindcss/src/compat/plugin-functions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { DesignSystem } from '../design-system'
22
import { ThemeOptions, type Theme, type ThemeKey } from '../theme'
33
import { withAlpha } from '../utilities'
44
import { DefaultMap } from '../utils/default-map'
5+
import { unescape } from '../utils/escape'
56
import { toKeyPath } from '../utils/to-key-path'
67
import { deepMerge } from './config/deep-merge'
78
import type { UserConfig } from './config/types'
@@ -37,7 +38,6 @@ export function createThemeFn(
3738
return cssValue
3839
}
3940

40-
//
4141
if (configValue !== null && typeof configValue === 'object' && !Array.isArray(configValue)) {
4242
let configValueCopy: Record<string, unknown> & { __CSS_VALUES__?: Record<string, number> } =
4343
// We want to make sure that we don't mutate the original config
@@ -70,7 +70,7 @@ export function createThemeFn(
7070
}
7171

7272
// CSS values from `@theme` win over values from the config
73-
configValueCopy[key] = cssValue[key]
73+
configValueCopy[unescape(key)] = cssValue[key]
7474
}
7575

7676
return configValueCopy
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { escape, unescape } from './escape'
3+
4+
describe('escape', () => {
5+
test('adds backslashes', () => {
6+
expect(escape(String.raw`red-1/2`)).toMatchInlineSnapshot(`"red-1\\/2"`)
7+
})
8+
})
9+
10+
describe('unescape', () => {
11+
test('removes backslashes', () => {
12+
expect(unescape(String.raw`red-1\/2`)).toMatchInlineSnapshot(`"red-1/2"`)
13+
})
14+
})

packages/tailwindcss/src/utils/escape.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,11 @@ export function escape(value: string) {
7171
}
7272
return result
7373
}
74+
75+
export function unescape(escaped: string) {
76+
return escaped.replace(/\\([\dA-Fa-f]{1,6}[\t\n\f\r ]?|[\S\s])/g, (match) => {
77+
return match.length > 2
78+
? String.fromCodePoint(Number.parseInt(match.slice(1).trim(), 16))
79+
: match[1]
80+
})
81+
}

0 commit comments

Comments
 (0)