Skip to content

Commit

Permalink
Escape JS theme configuration keys
Browse files Browse the repository at this point in the history
  • Loading branch information
philipp-spiess committed Oct 21, 2024
1 parent 5ce37c4 commit 99072ca
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 9 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Allow spaces spaces around operators in attribute selector variants ([#14703](https://github.com/tailwindlabs/tailwindcss/pull/14703))
- 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))
- _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))
Expand Down
10 changes: 10 additions & 0 deletions packages/tailwindcss/src/compat/apply-config-to-theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/tailwindcss/src/compat/apply-config-to-theme.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
)
}
Expand Down Expand Up @@ -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'
Expand Down
159 changes: 159 additions & 0 deletions packages/tailwindcss/src/compat/plugin-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
10 changes: 6 additions & 4 deletions packages/tailwindcss/src/compat/plugin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {}
Expand All @@ -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
}
}

Expand All @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions packages/tailwindcss/src/compat/plugin-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/tailwindcss/src/utils/escape.test.ts
Original file line number Diff line number Diff line change
@@ -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"`)
})
})
8 changes: 8 additions & 0 deletions packages/tailwindcss/src/utils/escape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
})
}

0 comments on commit 99072ca

Please sign in to comment.