Add support for prefixes (#14501)

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
<div class="tw:bg-white tw:p-4">
  This will have a white background and 4 units of padding.
</div>

<div class="bg-white p-4">
  This will not because the prefix is missing.
</div>
```

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 <hello@philippspiess.com>
This commit is contained in:
Jordan Pittman 2024-09-25 10:06:37 -04:00 • committed by GitHub
parent ceae1dbdfb
commit a144360217
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 597 additions and 6 deletions

View file

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

View file

@ -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",
},
],
},
]
`)
})

View file

@ -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

View file

@ -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

View file

@ -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.]`,
)
})

View file

@ -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

View file

@ -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
}

View file

@ -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.]`,
)
})

View file

@ -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 }) => {

View file

@ -8,6 +8,8 @@ export const enum ThemeOptions {
}
export class Theme {
public prefix: string | null = null
constructor(private values = new Map<string, { value: string; options: number }>()) {}
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 {