Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Escape JS theme configuration keys #14739

Merged
merged 1 commit into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
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 @@ -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', () => {
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]
})
}