From a1443602170f632435887d7fa4c7edcf72208d0f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 25 Sep 2024 10:06:37 -0400 Subject: [PATCH] Add support for prefixes (#14501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for requiring a custom prefix on utility classes. Prefixes work a bit differently in v4 than they did in v3: - They look like a custom variant: `tw:bg-white` - It is always first in a utility — even before other variants: `tw:hover:bg-white` - It is required on **all** utility classes — even arbitrary properties: `tw:[color:red]` - Prefixes also apply to generated CSS variables which will be separated by a dash: `--tw-color-white: #fff;` - Only alpha (a-z) characters are allowed in a prefix — so no `#tw#` or `__` or similar prefixes are allowed To configure a prefix you can use add `prefix(tw)` to your theme or when importing Tailwind CSS like so: ```css /* when importing `tailwindcss` */ @import 'tailwindcss' prefix(tw); /* when importing the theme separately */ @import 'tailwindcss/theme' prefix(tw); /* or when using an entirely custom theme */ @theme prefix(tw) { --color-white: #fff; --breakpoint-sm: 640px; /* … */ } ``` This will configure Tailwind CSS to require a prefix on all utility classes when used in HTML: ```html
This will have a white background and 4 units of padding.
This will not because the prefix is missing.
``` and when used in CSS via `@apply`: ```css .my-class { @apply tw:bg-white tw:p-4; } ``` Additionally, the prefix will be added to the generated CSS variables. You **do not** need to prefix the variables in the `@theme` block yourself — Tailwind CSS handles this automatically. ```css :root { --tw-color-white: #fff; --tw-breakpoint-sm: 640px; } ``` A prefix is not necessary when using the `theme(…)` function in your CSS or JS given that plugins will not know what the current prefix is and must work with or without a prefix: ```css .my-class { color: theme(--color-white); } ``` However, because the variables themselves are prefixed when outputting the CSS, you **do** need to prefix the variables when using `var(…)` in your CSS: ```css .my-class { color: var(--tw-color-white); } ``` If you want to customize the prefix itself change `tw` to something else: ```css /* my:underline, my:hover:bg-red-500, etc… */ @import 'tailwindcss' prefix(my); ``` --------- Co-authored-by: Philipp Spiess --- CHANGELOG.md | 4 + packages/tailwindcss/src/candidate.test.ts | 50 ++- packages/tailwindcss/src/candidate.ts | 9 + .../src/compat/apply-compat-hooks.ts | 21 ++ .../tailwindcss/src/compat/config.test.ts | 154 +++++++++ .../src/compat/config/resolve-config.ts | 7 +- .../tailwindcss/src/compat/config/types.ts | 9 + .../tailwindcss/src/compat/prefix.test.ts | 297 ++++++++++++++++++ packages/tailwindcss/src/index.ts | 36 ++- packages/tailwindcss/src/theme.ts | 16 +- 10 files changed, 597 insertions(+), 6 deletions(-) create mode 100644 packages/tailwindcss/src/compat/prefix.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 348dc578a..bdca568c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add support for prefixes ([#14501](https://github.com/tailwindlabs/tailwindcss/pull/14501)) + ### Fixed - _Experimental_: Improve codemod output, keep CSS after last Tailwind directive unlayered ([#14512](https://github.com/tailwindlabs/tailwindcss/pull/14512)) diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index 11e23c1a3..a0a84ce5c 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -6,12 +6,17 @@ import { Variants } from './variants' function run( candidate: string, - { utilities, variants }: { utilities?: Utilities; variants?: Variants } = {}, + { + utilities, + variants, + prefix, + }: { utilities?: Utilities; variants?: Variants; prefix?: string } = {}, ) { utilities ??= new Utilities() variants ??= new Variants() let designSystem = buildDesignSystem(new Theme()) + designSystem.theme.prefix = prefix ?? null designSystem.utilities = utilities designSystem.variants = variants @@ -1259,3 +1264,46 @@ it('should parse a variant containing an arbitrary string with unbalanced parens ] `) }) + +it('should parse candidates with a prefix', () => { + let utilities = new Utilities() + utilities.static('flex', () => []) + + let variants = new Variants() + variants.static('hover', () => {}) + + // A prefix is required + expect(run(`flex`, { utilities, variants, prefix: 'tw' })).toEqual([]) + + // The prefix always comes first — even before variants + expect(run(`tw:flex`, { utilities, variants, prefix: 'tw' })).toMatchInlineSnapshot(` + [ + { + "important": false, + "kind": "static", + "negative": false, + "raw": "tw:flex", + "root": "flex", + "variants": [], + }, + ] + `) + expect(run(`tw:hover:flex`, { utilities, variants, prefix: 'tw' })).toMatchInlineSnapshot(` + [ + { + "important": false, + "kind": "static", + "negative": false, + "raw": "tw:hover:flex", + "root": "flex", + "variants": [ + { + "compounds": true, + "kind": "static", + "root": "hover", + }, + ], + }, + ] + `) +}) diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index ea11e2fb7..792f95b58 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -226,6 +226,15 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter // ^^^^^^^^^ -> Base let rawVariants = segment(input, ':') + // A prefix is a special variant used to prefix all utilities. When present, + // all utilities must start with that variant which we will then remove from + // the variant list so no other part of the codebase has to know about it. + if (designSystem.theme.prefix) { + if (rawVariants[0] !== designSystem.theme.prefix) return null + + rawVariants.shift() + } + // Safety: At this point it is safe to use TypeScript's non-null assertion // operator because even if the `input` was an empty string, splitting an // empty string by `:` will always result in an array with at least one diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 4290c136a..1cf3cc810 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -13,6 +13,8 @@ import { buildPluginApi, type CssPluginOptions, type Plugin } from './plugin-api import { registerScreensConfig } from './screens-config' import { registerThemeVariantOverrides } from './theme-variants' +const IS_VALID_PREFIX = /^[a-z]+$/ + export async function applyCompatibilityHooks({ designSystem, base, @@ -208,6 +210,25 @@ export async function applyCompatibilityHooks({ registerThemeVariantOverrides(resolvedUserConfig, designSystem) registerScreensConfig(resolvedUserConfig, designSystem) + // If a prefix has already been set in CSS don't override it + if (!designSystem.theme.prefix && resolvedConfig.prefix) { + if (resolvedConfig.prefix.endsWith('-')) { + resolvedConfig.prefix = resolvedConfig.prefix.slice(0, -1) + + console.warn( + `The prefix "${resolvedConfig.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only and is written as a variant before all utilities. We have fixed up the prefix for you. Remove the trailing \`-\` to silence this warning.`, + ) + } + + if (!IS_VALID_PREFIX.test(resolvedConfig.prefix)) { + throw new Error( + `The prefix "${resolvedConfig.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.`, + ) + } + + designSystem.theme.prefix = resolvedConfig.prefix + } + // Replace `resolveThemeValue` with a version that is backwards compatible // with dot-notation but also aware of any JS theme configurations registered // by plugins or JS config files. This is significantly slower than just diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts index 2b8819a68..4725a8f2d 100644 --- a/packages/tailwindcss/src/compat/config.test.ts +++ b/packages/tailwindcss/src/compat/config.test.ts @@ -1217,3 +1217,157 @@ test('merges css breakpoints with js config screens', async () => { " `) }) + +test('utilities must be prefixed', async () => { + let input = css` + @tailwind utilities; + @config "./config.js"; + + @utility custom { + color: red; + } + ` + + let compiler = await compile(input, { + loadModule: async (id, base) => ({ + base, + module: { prefix: 'tw' }, + }), + }) + + // Prefixed utilities are generated + expect(compiler.build(['tw:underline', 'tw:hover:line-through', 'tw:custom'])) + .toMatchInlineSnapshot(` + ".tw\\:custom { + color: red; + } + .tw\\:underline { + text-decoration-line: underline; + } + .tw\\:hover\\:line-through { + &:hover { + @media (hover: hover) { + text-decoration-line: line-through; + } + } + } + " + `) + + // Non-prefixed utilities are ignored + compiler = await compile(input, { + loadModule: async (id, base) => ({ + base, + module: { prefix: 'tw' }, + }), + }) + + expect(compiler.build(['underline', 'hover:line-through', 'custom'])).toEqual('') +}) + +test('utilities used in @apply must be prefixed', async () => { + let compiler = await compile( + css` + @config "./config.js"; + + .my-underline { + @apply tw:underline; + } + `, + { + loadModule: async (id, base) => ({ + base, + module: { prefix: 'tw' }, + }), + }, + ) + + // Prefixed utilities are generated + expect(compiler.build([])).toMatchInlineSnapshot(` + ".my-underline { + text-decoration-line: underline; + } + " + `) + + // Non-prefixed utilities cause an error + expect(() => + compile( + css` + @config "./config.js"; + + .my-underline { + @apply underline; + } + `, + { + loadModule: async (id, base) => ({ + base, + module: { prefix: 'tw' }, + }), + }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot apply unknown utility class: underline]`, + ) +}) + +test('Prefixes configured in CSS take precedence over those defined in JS configs', async () => { + let compiler = await compile( + css` + @theme prefix(wat) { + --color-red: #f00; + --color-green: #0f0; + --breakpoint-sm: 640px; + } + + @config "./plugin.js"; + + @tailwind utilities; + + @utility custom { + color: red; + } + `, + { + async loadModule(id, base) { + return { + base, + module: { prefix: 'tw' }, + } + }, + }, + ) + + expect(compiler.build(['wat:custom'])).toMatchInlineSnapshot(` + ":root { + --wat-color-red: #f00; + --wat-color-green: #0f0; + --wat-breakpoint-sm: 640px; + } + .wat\\:custom { + color: red; + } + " + `) +}) + +test('a prefix must be letters only', async () => { + await expect(() => + compile( + css` + @config "./plugin.js"; + `, + { + async loadModule(id, base) { + return { + base, + module: { prefix: '__' }, + } + }, + }, + ), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The prefix "__" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.]`, + ) +}) diff --git a/packages/tailwindcss/src/compat/config/resolve-config.ts b/packages/tailwindcss/src/compat/config/resolve-config.ts index 1f59b50cf..0c20bf7c3 100644 --- a/packages/tailwindcss/src/compat/config/resolve-config.ts +++ b/packages/tailwindcss/src/compat/config/resolve-config.ts @@ -27,6 +27,7 @@ interface ResolutionContext { } let minimal: ResolvedConfig = { + prefix: '', darkMode: null, theme: {}, plugins: [], @@ -54,11 +55,15 @@ export function resolveConfig(design: DesignSystem, files: ConfigFile[]): Resolv extractConfigs(ctx, file) } - // Merge dark mode + // Merge top level keys for (let config of ctx.configs) { if ('darkMode' in config && config.darkMode !== undefined) { ctx.result.darkMode = config.darkMode ?? null } + + if ('prefix' in config && config.prefix !== undefined) { + ctx.result.prefix = config.prefix ?? '' + } } // Merge themes diff --git a/packages/tailwindcss/src/compat/config/types.ts b/packages/tailwindcss/src/compat/config/types.ts index 5a194e96e..4b6e072b5 100644 --- a/packages/tailwindcss/src/compat/config/types.ts +++ b/packages/tailwindcss/src/compat/config/types.ts @@ -69,3 +69,12 @@ export interface UserConfig { export interface ResolvedConfig { darkMode: DarkModeStrategy | null } + +// `prefix` support +export interface UserConfig { + prefix?: string +} + +export interface ResolvedConfig { + prefix: string +} diff --git a/packages/tailwindcss/src/compat/prefix.test.ts b/packages/tailwindcss/src/compat/prefix.test.ts new file mode 100644 index 000000000..85091b22f --- /dev/null +++ b/packages/tailwindcss/src/compat/prefix.test.ts @@ -0,0 +1,297 @@ +import { expect, test } from 'vitest' +import { compile } from '..' +import plugin from '../plugin' + +const css = String.raw + +test('utilities must be prefixed', async () => { + let input = css` + @theme reference prefix(tw); + @tailwind utilities; + + @utility custom { + color: red; + } + ` + + let compiler = await compile(input) + + // Prefixed utilities are generated + expect(compiler.build(['tw:underline', 'tw:hover:line-through', 'tw:custom'])) + .toMatchInlineSnapshot(` + ".tw\\:custom { + color: red; + } + .tw\\:underline { + text-decoration-line: underline; + } + .tw\\:hover\\:line-through { + &:hover { + @media (hover: hover) { + text-decoration-line: line-through; + } + } + } + " + `) + + // Non-prefixed utilities are ignored + compiler = await compile(input) + + expect(compiler.build(['underline', 'hover:line-through', 'custom'])).toEqual('') +}) + +test('utilities used in @apply must be prefixed', async () => { + let compiler = await compile(css` + @theme reference prefix(tw); + + .my-underline { + @apply tw:underline; + } + `) + + // Prefixed utilities are generated + expect(compiler.build([])).toMatchInlineSnapshot(` + ".my-underline { + text-decoration-line: underline; + } + " + `) + + // Non-prefixed utilities cause an error + expect(() => + compile(css` + @theme reference prefix(tw); + + .my-underline { + @apply underline; + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot apply unknown utility class: underline]`, + ) +}) + +test('CSS variables output by the theme are prefixed', async () => { + let compiler = await compile(css` + @theme prefix(tw) { + --color-red: #f00; + --color-green: #0f0; + --breakpoint-sm: 640px; + } + + @tailwind utilities; + `) + + // Prefixed utilities are generated + expect(compiler.build(['tw:text-red'])).toMatchInlineSnapshot(` + ":root { + --tw-color-red: #f00; + --tw-color-green: #0f0; + --tw-breakpoint-sm: 640px; + } + .tw\\:text-red { + color: var(--tw-color-red, #f00); + } + " + `) +}) + +test('CSS theme functions do not use the prefix', async () => { + let compiler = await compile(css` + @theme prefix(tw) { + --color-red: #f00; + --color-green: #0f0; + --breakpoint-sm: 640px; + } + + @tailwind utilities; + `) + + expect(compiler.build(['tw:[color:theme(--color-red)]', 'tw:text-[theme(--color-red)]'])) + .toMatchInlineSnapshot(` + ":root { + --tw-color-red: #f00; + --tw-color-green: #0f0; + --tw-breakpoint-sm: 640px; + } + .tw\\:\\[color\\:theme\\(--color-red\\)\\] { + color: #f00; + } + .tw\\:text-\\[theme\\(--color-red\\)\\] { + color: #f00; + } + " + `) + + compiler = await compile(css` + @theme reference prefix(tw) { + --color-red: #f00; + --color-green: #0f0; + --breakpoint-sm: 640px; + } + + @tailwind utilities; + `) + + expect( + compiler.build(['tw:[color:theme(--tw-color-red)]', 'tw:text-[theme(--tw-color-red)]']), + ).toEqual('') +}) + +test('JS theme functions do not use the prefix', async () => { + let compiler = await compile( + css` + @theme prefix(tw) { + --color-red: #f00; + --color-green: #0f0; + --breakpoint-sm: 640px; + } + + @plugin "./plugin.js"; + + @tailwind utilities; + `, + { + async loadModule(id, base) { + return { + base, + module: plugin(({ addUtilities, theme }) => { + addUtilities({ + '.my-custom': { + color: theme('--color-red'), + }, + }) + + // The theme function does not use the prefix + expect(theme('--tw-color-red')).toEqual(undefined) + }), + } + }, + }, + ) + + expect(compiler.build(['tw:my-custom'])).toMatchInlineSnapshot(` + ":root { + --tw-color-red: #f00; + --tw-color-green: #0f0; + --tw-breakpoint-sm: 640px; + } + .tw\\:my-custom { + color: #f00; + } + " + `) +}) + +test('a prefix can be configured via @import theme(…)', async () => { + let input = css` + @import 'tailwindcss/theme' theme(reference prefix(tw)); + @tailwind utilities; + + @utility custom { + color: red; + } + ` + + let compiler = await compile(input, { + async loadStylesheet(id, base) { + return { + base, + content: '@theme {}', + } + }, + }) + + // Prefixed utilities are generated + expect(compiler.build(['tw:underline', 'tw:hover:line-through', 'tw:custom'])) + .toMatchInlineSnapshot(` + ".tw\\:custom { + color: red; + } + .tw\\:underline { + text-decoration-line: underline; + } + .tw\\:hover\\:line-through { + &:hover { + @media (hover: hover) { + text-decoration-line: line-through; + } + } + } + " + `) + + // Non-prefixed utilities are ignored + compiler = await compile(input, { + async loadStylesheet(id, base) { + return { + base, + content: '@theme {}', + } + }, + }) + + expect(compiler.build(['underline', 'hover:line-through', 'custom'])).toEqual('') +}) + +test('a prefix can be configured via @import prefix(…)', async () => { + let input = css` + @import 'tailwindcss/theme' prefix(tw); + @tailwind utilities; + + @utility custom { + color: red; + } + ` + + let compiler = await compile(input, { + async loadStylesheet(id, base) { + return { + base, + content: '@theme reference {}', + } + }, + }) + + // Prefixed utilities are generated + expect(compiler.build(['tw:underline', 'tw:hover:line-through', 'tw:custom'])) + .toMatchInlineSnapshot(` + ".tw\\:custom { + color: red; + } + .tw\\:underline { + text-decoration-line: underline; + } + .tw\\:hover\\:line-through { + &:hover { + @media (hover: hover) { + text-decoration-line: line-through; + } + } + } + " + `) + + // Non-prefixed utilities are ignored + compiler = await compile(input, { + async loadStylesheet(id, base) { + return { + base, + content: '@theme reference {}', + } + }, + }) + + expect(compiler.build(['underline', 'hover:line-through', 'custom'])).toEqual('') +}) + +test('a prefix must be letters only', async () => { + let input = css` + @theme reference prefix(__); + ` + + await expect(() => compile(input)).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The prefix "__" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.]`, + ) +}) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 6ded20cd4..f3c7ee674 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -23,6 +23,7 @@ import { Theme, ThemeOptions } from './theme' import { segment } from './utils/segment' export type Config = UserConfig +const IS_VALID_PREFIX = /^[a-z]+$/ const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/ type CompileOptions = { @@ -45,6 +46,7 @@ function throwOnLoadStylesheet(): never { function parseThemeOptions(selector: string) { let options = ThemeOptions.NONE + let prefix = null for (let option of segment(selector.slice(6) /* '@theme'.length */, ' ')) { if (option === 'reference') { @@ -53,10 +55,12 @@ function parseThemeOptions(selector: string) { options |= ThemeOptions.INLINE } else if (option === 'default') { options |= ThemeOptions.DEFAULT + } else if (option.startsWith('prefix(') && option.endsWith(')')) { + prefix = option.slice(7, -1) } } - return options + return [options, prefix] as const } async function parseCss( @@ -210,9 +214,37 @@ async function parseCss( return WalkAction.Skip } + // Drop instances of `@media prefix(…)` + // + // We support `@import "tailwindcss" prefix(ident)` as a way to + // configure a theme prefix for variables and utilities. + if (node.selector.startsWith('@media prefix(')) { + let themeParams = node.selector.slice(7) + + walk(node.nodes, (child) => { + if (child.kind !== 'rule') return + if (child.selector === '@theme' || child.selector.startsWith('@theme ')) { + child.selector += ' ' + themeParams + return WalkAction.Skip + } + }) + replaceWith(node.nodes) + return WalkAction.Skip + } + if (node.selector !== '@theme' && !node.selector.startsWith('@theme ')) return - let themeOptions = parseThemeOptions(node.selector) + let [themeOptions, themePrefix] = parseThemeOptions(node.selector) + + if (themePrefix) { + if (!IS_VALID_PREFIX.test(themePrefix)) { + throw new Error( + `The prefix "${themePrefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.`, + ) + } + + theme.prefix = themePrefix + } // Record all custom properties in the `@theme` declaration walk(node.nodes, (child, { replaceWith }) => { diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index ea73b6ebc..2b7510d4d 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -8,6 +8,8 @@ export const enum ThemeOptions { } export class Theme { + public prefix: string | null = null + constructor(private values = new Map()) {} add(key: string, value: string, options = ThemeOptions.NONE): void { @@ -74,7 +76,17 @@ export class Theme { } entries() { - return this.values.entries() + if (!this.prefix) return this.values.entries() + + return Array.from(this.values, (entry) => { + entry[0] = this.#prefixKey(entry[0]) + return entry + }) + } + + #prefixKey(key: string) { + if (!this.prefix) return key + return `--${this.prefix}-${key.slice(2)}` } #clearNamespace(namespace: string) { @@ -103,7 +115,7 @@ export class Theme { return null } - return `var(${themeKey}, ${this.values.get(themeKey)?.value})` + return `var(${this.#prefixKey(themeKey)}, ${this.values.get(themeKey)?.value})` } resolve(candidateValue: string | null, themeKeys: ThemeKey[]): string | null {