diff --git a/CHANGELOG.md b/CHANGELOG.md index 5390fa10f..3e8dabac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Nothing yet! +### Added + +- Support `@theme reference { … }` for defining theme values without emitting variables ([#13222](https://github.com/tailwindlabs/tailwindcss/pull/13222)) ## [4.0.0-alpha.8] - 2024-03-11 diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 1488b4817..d4b6eb614 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -948,4 +948,135 @@ describe('Parsing themes values from CSS', () => { }" `) }) + + test('theme values added as reference are not included in the output as variables', () => { + expect( + compileCss( + css` + @theme { + --color-tomato: #e10c04; + } + @theme reference { + --color-potato: #ac855b; + } + @tailwind utilities; + `, + ['bg-tomato', 'bg-potato'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-tomato: #e10c04; + } + + .bg-potato { + background-color: #ac855b; + } + + .bg-tomato { + background-color: #e10c04; + }" + `) + }) + + test('theme values added as reference that override existing theme value suppress the output of the original theme value as a variable', () => { + expect( + compileCss( + css` + @theme { + --color-potato: #ac855b; + } + @theme reference { + --color-potato: #c794aa; + } + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ".bg-potato { + background-color: #c794aa; + }" + `) + }) + + test('overriding a reference theme value with a non-reference theme value includes it in the output as a variable', () => { + expect( + compileCss( + css` + @theme reference { + --color-potato: #ac855b; + } + @theme { + --color-potato: #c794aa; + } + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-potato: #c794aa; + } + + .bg-potato { + background-color: #c794aa; + }" + `) + }) + + test('wrapping `@theme` with `@media reference` behaves like `@theme reference` to support `@import` statements', () => { + expect( + compileCss( + css` + @theme { + --color-tomato: #e10c04; + } + @media reference { + @theme { + --color-potato: #ac855b; + } + @theme { + --color-avocado: #c0cc6d; + } + } + @tailwind utilities; + `, + ['bg-tomato', 'bg-potato', 'bg-avocado'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-tomato: #e10c04; + } + + .bg-avocado { + background-color: #c0cc6d; + } + + .bg-potato { + background-color: #ac855b; + } + + .bg-tomato { + background-color: #e10c04; + }" + `) + }) + + test('`@media reference` can only contain `@theme` rules', () => { + expect(() => + compileCss( + css` + @media reference { + .not-a-theme-rule { + color: cursed; + } + } + @tailwind utilities; + `, + ['bg-tomato', 'bg-potato', 'bg-avocado'], + ), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Files imported with \`@import "…" reference\` must only contain \`@theme\` blocks.]`, + ) + }) }) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 9678028f0..9e019c924 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -28,25 +28,47 @@ export function compile(css: string): { walk(ast, (node, { replaceWith }) => { if (node.kind !== 'rule') return - if (node.selector !== '@theme') return + + // Drop instances of `@media reference` + // + // We support `@import "tailwindcss/theme" reference` as a way to import an external theme file + // as a reference, which becomes `@media reference { … }` when the `@import` is processed. + if (node.selector === '@media reference') { + walk(node.nodes, (child) => { + if (child.kind !== 'rule') { + throw new Error( + 'Files imported with `@import "…" reference` must only contain `@theme` blocks.', + ) + } + if (child.selector === '@theme') { + child.selector = '@theme reference' + return WalkAction.Skip + } + }) + replaceWith(node.nodes) + } + + if (node.selector !== '@theme' && node.selector !== '@theme reference') return + + let isReference = node.selector === '@theme reference' // Record all custom properties in the `@theme` declaration - walk(node.nodes, (node, { replaceWith }) => { + walk(node.nodes, (child, { replaceWith }) => { // Collect `@keyframes` rules to re-insert with theme variables later, // since the `@theme` rule itself will be removed. - if (node.kind === 'rule' && node.selector.startsWith('@keyframes ')) { - keyframesRules.push(node) + if (child.kind === 'rule' && child.selector.startsWith('@keyframes ')) { + keyframesRules.push(child) replaceWith([]) return WalkAction.Skip } - if (node.kind === 'comment') return - if (node.kind === 'declaration' && node.property.startsWith('--')) { - theme.add(node.property, node.value) + if (child.kind === 'comment') return + if (child.kind === 'declaration' && child.property.startsWith('--')) { + theme.add(child.property, child.value, isReference) return } - let snippet = toCss([rule('@theme', [node])]) + let snippet = toCss([rule(node.selector, [child])]) .split('\n') .map((line, idx, all) => `${idx === 0 || idx >= all.length - 2 ? ' ' : '>'} ${line}`) .join('\n') @@ -58,7 +80,7 @@ export function compile(css: string): { // Keep a reference to the first `@theme` rule to update with the full theme // later, and delete any other `@theme` rules. - if (!firstThemeRule) { + if (!firstThemeRule && !isReference) { firstThemeRule = node } else { replaceWith([]) @@ -75,7 +97,8 @@ export function compile(css: string): { let nodes = [] for (let [key, value] of theme.entries()) { - nodes.push(decl(key, value)) + if (value.isReference) continue + nodes.push(decl(key, value.value)) } if (keyframesRules.length > 0) { @@ -158,23 +181,6 @@ export function compile(css: string): { }) } - // Drop instances of `@media reference` - // - // We allow importing a theme as a reference so users can define the theme for - // the current CSS file without duplicating the theme vars in the final CSS. - // This is useful for users who use `@apply` in Vue SFCs and in CSS modules. - // - // The syntax is derived from `@import "tailwindcss/theme" reference` which - // turns into `@media reference { … }` in the final CSS. - if (css.includes('@media reference')) { - walk(ast, (node, { replaceWith }) => { - if (node.kind === 'rule' && node.selector === '@media reference') { - replaceWith([]) - return WalkAction.Skip - } - }) - } - // Track all valid candidates, these are the incoming `rawCandidate` that // resulted in a generated AST Node. All the other `rawCandidates` are invalid // and should be ignored. @@ -255,14 +261,15 @@ export function __unstable__loadDesignSystem(css: string) { walk(ast, (node) => { if (node.kind !== 'rule') return - if (node.selector !== '@theme') return + if (node.selector !== '@theme' && node.selector !== '@theme reference') return + let isReference = node.selector === '@theme reference' // Record all custom properties in the `@theme` declaration walk([node], (node) => { if (node.kind !== 'declaration') return if (!node.property.startsWith('--')) return - theme.add(node.property, node.value) + theme.add(node.property, node.value, isReference) }) }) diff --git a/packages/tailwindcss/src/intellisense.test.ts b/packages/tailwindcss/src/intellisense.test.ts index a7cce5618..596bad167 100644 --- a/packages/tailwindcss/src/intellisense.test.ts +++ b/packages/tailwindcss/src/intellisense.test.ts @@ -3,22 +3,18 @@ import { buildDesignSystem } from './design-system' import { Theme } from './theme' function loadDesignSystem() { - return buildDesignSystem( - new Theme( - new Map([ - ['--spacing-0_5', '0.125rem'], - ['--spacing-1', '0.25rem'], - ['--spacing-3', '0.75rem'], - ['--spacing-4', '1rem'], - ['--width-4', '1rem'], - ['--colors-red-500', 'red'], - ['--colors-blue-500', 'blue'], - ['--breakpoint-sm', '640px'], - ['--font-size-xs', '0.75rem'], - ['--font-size-xs--line-height', '1rem'], - ]), - ), - ) + let theme = new Theme() + theme.add('--spacing-0_5', '0.125rem') + theme.add('--spacing-1', '0.25rem') + theme.add('--spacing-3', '0.75rem') + theme.add('--spacing-4', '1rem') + theme.add('--width-4', '1rem') + theme.add('--colors-red-500', 'red') + theme.add('--colors-blue-500', 'blue') + theme.add('--breakpoint-sm', '640px') + theme.add('--font-size-xs', '0.75rem') + theme.add('--font-size-xs--line-height', '1rem') + return buildDesignSystem(theme) } test('getClassList', () => { diff --git a/packages/tailwindcss/src/sort.bench.ts b/packages/tailwindcss/src/sort.bench.ts index dce0132d3..907793c37 100644 --- a/packages/tailwindcss/src/sort.bench.ts +++ b/packages/tailwindcss/src/sort.bench.ts @@ -4,17 +4,15 @@ import { Theme } from './theme' const input = 'a-class px-3 p-1 b-class py-3 bg-red-500 bg-blue-500'.split(' ') const emptyDesign = buildDesignSystem(new Theme()) -const simpleDesign = buildDesignSystem( - new Theme( - new Map([ - ['--spacing-1', '0.25rem'], - ['--spacing-3', '0.75rem'], - ['--spacing-4', '1rem'], - ['--color-red-500', 'red'], - ['--color-blue-500', 'blue'], - ]), - ), -) +const simpleDesign = (() => { + let simpleTheme = new Theme() + simpleTheme.add('--spacing-1', '0.25rem') + simpleTheme.add('--spacing-3', '0.75rem') + simpleTheme.add('--spacing-4', '1rem') + simpleTheme.add('--color-red-500', 'red') + simpleTheme.add('--color-blue-500', 'blue') + return buildDesignSystem(simpleTheme) +})() bench('getClassOrder (empty theme)', () => { emptyDesign.getClassOrder(input) diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 68cecbc16..9fcfef4c6 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -1,9 +1,9 @@ import { escape } from './utils/escape' export class Theme { - constructor(private values: Map = new Map()) {} + constructor(private values = new Map()) {} - add(key: string, value: string): void { + add(key: string, value: string, isReference = false): void { if (key.endsWith('-*')) { if (value !== 'initial') { throw new Error(`Invalid theme value \`${value}\` for namespace \`${key}\``) @@ -18,7 +18,7 @@ export class Theme { if (value === 'initial') { this.values.delete(key) } else { - this.values.set(key, value) + this.values.set(key, { value, isReference }) } } @@ -46,7 +46,7 @@ export class Theme { for (let key of themeKeys) { let value = this.values.get(key) if (value) { - return value + return value.value } } @@ -82,7 +82,7 @@ export class Theme { if (!themeKey) return null - return this.values.get(themeKey)! + return this.values.get(themeKey)!.value } resolveWith( @@ -98,11 +98,11 @@ export class Theme { for (let name of nestedKeys) { let nestedValue = this.values.get(`${themeKey}${name}`) if (nestedValue) { - extra[name] = nestedValue + extra[name] = nestedValue.value } } - return [this.values.get(themeKey)!, extra] + return [this.values.get(themeKey)!.value, extra] } namespace(namespace: string) { @@ -111,9 +111,9 @@ export class Theme { for (let [key, value] of this.values) { if (key === namespace) { - values.set(null, value) + values.set(null, value.value) } else if (key.startsWith(prefix)) { - values.set(key.slice(prefix.length), value) + values.set(key.slice(prefix.length), value.value) } }