Skip to content

Commit f99717b

Browse files
Escape JS theme configuration keys
1 parent 557ed8c commit f99717b

File tree

8 files changed

+209
-9
lines changed

8 files changed

+209
-9
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Fixed
1616

1717
- Allow spaces spaces around operators in attribute selector variants ([#14703](https://github.com/tailwindlabs/tailwindcss/pull/14703))
18+
<<<<<<< HEAD
1819
- Ensure color opacity modifiers work with OKLCH colors ([#14741](https://github.com/tailwindlabs/tailwindcss/pull/14741))
1920
- Ensure changes to the input CSS file result in a full rebuild ([#14744](https://github.com/tailwindlabs/tailwindcss/pull/14744))
2021
- Add `postcss` as a dependency of `@tailwindcss/postcss` ([#14750](https://github.com/tailwindlabs/tailwindcss/pull/14750))
22+
||||||| parent of 99072cae (Escape JS theme configuration keys)
23+
=======
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))
26+
>>>>>>> 99072cae (Escape JS theme configuration keys)
2127
- _Upgrade (experimental)_: Migrate `flex-grow` to `grow` and `flex-shrink` to `shrink` ([#14721](https://github.com/tailwindlabs/tailwindcss/pull/14721))
2228
- _Upgrade (experimental)_: Minify arbitrary values when printing candidates ([#14720](https://github.com/tailwindlabs/tailwindcss/pull/14720))
2329
- _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))

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
@@ -1209,6 +1209,165 @@ describe('theme', async () => {
12091209
"
12101210
`)
12111211
})
1212+
1213+
test('can use escaped JS variables in theme values', async () => {
1214+
let input = css`
1215+
@tailwind utilities;
1216+
@plugin "my-plugin";
1217+
`
1218+
1219+
let compiler = await compile(input, {
1220+
loadModule: async (id, base) => {
1221+
return {
1222+
base,
1223+
module: plugin(
1224+
function ({ matchUtilities, theme }) {
1225+
matchUtilities(
1226+
{ 'my-width': (value) => ({ width: value }) },
1227+
{ values: theme('width') },
1228+
)
1229+
},
1230+
{
1231+
theme: {
1232+
extend: {
1233+
width: {
1234+
'1': '0.25rem',
1235+
// Purposely setting to something different from the v3 default
1236+
'1/2': '60%',
1237+
'1.5': '0.375rem',
1238+
},
1239+
},
1240+
},
1241+
},
1242+
),
1243+
}
1244+
},
1245+
})
1246+
1247+
expect(compiler.build(['my-width-1', 'my-width-1/2', 'my-width-1.5'])).toMatchInlineSnapshot(
1248+
`
1249+
".my-width-1 {
1250+
width: 0.25rem;
1251+
}
1252+
.my-width-1\\.5 {
1253+
width: 0.375rem;
1254+
}
1255+
.my-width-1\\/2 {
1256+
width: 60%;
1257+
}
1258+
"
1259+
`,
1260+
)
1261+
})
1262+
1263+
test('can use escaped CSS variables in theme values', async () => {
1264+
let input = css`
1265+
@tailwind utilities;
1266+
@plugin "my-plugin";
1267+
1268+
@theme {
1269+
--width-1: 0.25rem;
1270+
/* Purposely setting to something different from the v3 default */
1271+
--width-1\/2: 60%;
1272+
--width-1\.5: 0.375rem;
1273+
--width-2_5: 0.625rem;
1274+
}
1275+
`
1276+
1277+
let compiler = await compile(input, {
1278+
loadModule: async (id, base) => {
1279+
return {
1280+
base,
1281+
module: plugin(function ({ matchUtilities, theme }) {
1282+
matchUtilities(
1283+
{ 'my-width': (value) => ({ width: value }) },
1284+
{ values: theme('width') },
1285+
)
1286+
}),
1287+
}
1288+
},
1289+
})
1290+
1291+
expect(compiler.build(['my-width-1', 'my-width-1.5', 'my-width-1/2', 'my-width-2.5']))
1292+
.toMatchInlineSnapshot(`
1293+
".my-width-1 {
1294+
width: 0.25rem;
1295+
}
1296+
.my-width-1\\.5 {
1297+
width: 0.375rem;
1298+
}
1299+
.my-width-1\\/2 {
1300+
width: 60%;
1301+
}
1302+
.my-width-2\\.5 {
1303+
width: 0.625rem;
1304+
}
1305+
:root {
1306+
--width-1: 0.25rem;
1307+
--width-1\\/2: 60%;
1308+
--width-1\\.5: 0.375rem;
1309+
--width-2_5: 0.625rem;
1310+
}
1311+
"
1312+
`)
1313+
})
1314+
1315+
test('can use escaped CSS variables in referenced theme namespace', async () => {
1316+
let input = css`
1317+
@tailwind utilities;
1318+
@plugin "my-plugin";
1319+
1320+
@theme {
1321+
--width-1: 0.25rem;
1322+
/* Purposely setting to something different from the v3 default */
1323+
--width-1\/2: 60%;
1324+
--width-1\.5: 0.375rem;
1325+
--width-2_5: 0.625rem;
1326+
}
1327+
`
1328+
1329+
let compiler = await compile(input, {
1330+
loadModule: async (id, base) => {
1331+
return {
1332+
base,
1333+
module: plugin(
1334+
function ({ matchUtilities, theme }) {
1335+
matchUtilities(
1336+
{ 'my-width': (value) => ({ width: value }) },
1337+
{ values: theme('myWidth') },
1338+
)
1339+
},
1340+
{
1341+
theme: { myWidth: ({ theme }) => theme('width') },
1342+
},
1343+
),
1344+
}
1345+
},
1346+
})
1347+
1348+
expect(compiler.build(['my-width-1', 'my-width-1.5', 'my-width-1/2', 'my-width-2.5']))
1349+
.toMatchInlineSnapshot(`
1350+
".my-width-1 {
1351+
width: 0.25rem;
1352+
}
1353+
.my-width-1\\.5 {
1354+
width: 0.375rem;
1355+
}
1356+
.my-width-1\\/2 {
1357+
width: 60%;
1358+
}
1359+
.my-width-2\\.5 {
1360+
width: 0.625rem;
1361+
}
1362+
:root {
1363+
--width-1: 0.25rem;
1364+
--width-1\\/2: 60%;
1365+
--width-1\\.5: 0.375rem;
1366+
--width-2_5: 0.625rem;
1367+
}
1368+
"
1369+
`)
1370+
})
12121371
})
12131372

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