Add @source inline(…) (#17147)

This PR adds a new experimental feature that can be used to force-inline
utilities based on an input string. The idea is that all utilities
matching the source string will be included in your CSS:

```css
/* input.css */
@source inline('underline');

/* output.css */
.underline {
   text-decoration: underline;
}
```

Additionally, the source input is brace-expanded, meaning you can use
one line to inline a whole namespace easily:
```css
/* input.css */
@source inline('{hover:,}bg-red-{50,{100..900..100},950}');

/* output.css */
.bg-red-50 {
  background-color: var(--color-red-50);
}
.bg-red-100 {
  background-color: var(--color-red-100);
}
.bg-red-200 {
  background-color: var(--color-red-200);
}
.bg-red-300 {
  background-color: var(--color-red-300);
}
.bg-red-400 {
  background-color: var(--color-red-400);
}
.bg-red-500 {
  background-color: var(--color-red-500);
}
.bg-red-600 {
  background-color: var(--color-red-600);
}
.bg-red-700 {
  background-color: var(--color-red-700);
}
.bg-red-800 {
  background-color: var(--color-red-800);
}
.bg-red-900 {
  background-color: var(--color-red-900);
}
.bg-red-950 {
  background-color: var(--color-red-950);
}
@media (hover: hover) {
  .hover\\:bg-red-50:hover {
    background-color: var(--color-red-50);
  }
  .hover\\:bg-red-100:hover {
    background-color: var(--color-red-100);
  }
  .hover\\:bg-red-200:hover {
    background-color: var(--color-red-200);
  }
  .hover\\:bg-red-300:hover {
    background-color: var(--color-red-300);
  }
  .hover\\:bg-red-400:hover {
    background-color: var(--color-red-400);
  }
  .hover\\:bg-red-500:hover {
    background-color: var(--color-red-500);
  }
  .hover\\:bg-red-600:hover {
    background-color: var(--color-red-600);
  }
  .hover\\:bg-red-700:hover {
    background-color: var(--color-red-700);
  }
  .hover\\:bg-red-800:hover {
    background-color: var(--color-red-800);
  }
  .hover\\:bg-red-900:hover {
    background-color: var(--color-red-900);
  }
  .hover\\:bg-red-950:hover {
    background-color: var(--color-red-950);
  }
}

```

This feature is also compatible with the `not` keyword that we're about
to add to `@source "…"` in a follow-up PR. This can be used to set up an
ignore list purely in CSS. The following code snippet, for example, will
ensure that the `.container` utility is never created:

```css
@theme {
  --breakpoint-sm: 40rem;
  --breakpoint-md: 48rem;
  --breakpoint-lg: 64rem;
  --breakpoint-xl: 80rem;
  --breakpoint-2xl: 96rem;
}
@source not inline("container");
@tailwind utilities;
```

## Test plan

- See added unit tests
- The new brace expansion library was also benchmarked against the
popular `braces` library:
  ```
  clk: ~3.96 GHz
  cpu: Apple M4 Max
  runtime: bun 1.1.34 (arm64-darwin)

benchmark avg (min … max) p75 / p99 (min … top 1%)
-------------------------------------------
-------------------------------
braces 31.05 ms/iter 32.35 ms █ █
(28.14 ms … 36.35 ms) 35.14 ms ██ █
( 0.00 b … 116.45 mb) 18.71 mb ██████▁▁▁██▁█▁██▁█▁▁█

./brace-expansion 19.34 ms/iter 21.69 ms █
(12.53 ms … 26.63 ms) 25.53 ms ▅ ▅ █ █
( 0.00 b … 114.13 mb) 11.86 mb █▁▅▁██▁▅█▅█▅▁█▅█▅▅▁▅█

┌ ┐
╷┌────┬─┐ ╷
braces ├┤ │ ├─────┤
╵└────┴─┘ ╵
                              ╷        ┌────┬───┐       ╷
            ./brace-expansion ├────────┤    │   ├───────┤
                              ╵        └────┴───┘       ╵
└ ┘
12.53 ms 23.84 ms 35.14 ms
  ```

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Philipp Spiess 2025-03-12 14:55:33 +01:00 committed by GitHub
parent ca408d0612
commit 215f4f348b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 430 additions and 3 deletions

View File

@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- _Experimental_: Add `any-pointer-none`, `any-pointer-coarse`, and `any-pointer-fine` variants ([#16941](https://github.com/tailwindlabs/tailwindcss/pull/16941))
- _Experimental_: Add `user-valid` and `user-invalid` variants ([#12370](https://github.com/tailwindlabs/tailwindcss/pull/12370))
- _Experimental_: Add `wrap-anywhere`, `wrap-break-word`, and `wrap-normal` utilities ([#12128](https://github.com/tailwindlabs/tailwindcss/pull/12128))
- _Experimental_: Add `@source inline(…)` ([#17147](https://github.com/tailwindlabs/tailwindcss/pull/17147))
### Fixed

View File

@ -2,5 +2,6 @@ export const enableDetailsContent = process.env.FEATURES_ENV !== 'stable'
export const enableInvertedColors = process.env.FEATURES_ENV !== 'stable'
export const enablePointerVariants = process.env.FEATURES_ENV !== 'stable'
export const enableScripting = process.env.FEATURES_ENV !== 'stable'
export const enableSourceInline = process.env.FEATURES_ENV !== 'stable'
export const enableUserValid = process.env.FEATURES_ENV !== 'stable'
export const enableWrapAnywhere = process.env.FEATURES_ENV !== 'stable'

View File

@ -3194,6 +3194,185 @@ describe('@source', () => {
{ pattern: './php/secr3t/smarty.php', base: '/root' },
])
})
describe('@source inline(…)', () => {
test('always includes the candidate', async () => {
let { build } = await compile(
css`
@source inline("underline");
@tailwind utilities;
`,
{ base: '/root' },
)
expect(build([])).toMatchInlineSnapshot(`
".underline {
text-decoration-line: underline;
}
"
`)
})
test('applies brace expansion', async () => {
let { build } = await compile(
css`
@theme {
--color-red-50: oklch(0.971 0.013 17.38);
--color-red-100: oklch(0.936 0.032 17.717);
--color-red-200: oklch(0.885 0.062 18.334);
--color-red-300: oklch(0.808 0.114 19.571);
--color-red-400: oklch(0.704 0.191 22.216);
--color-red-500: oklch(0.637 0.237 25.331);
--color-red-600: oklch(0.577 0.245 27.325);
--color-red-700: oklch(0.505 0.213 27.518);
--color-red-800: oklch(0.444 0.177 26.899);
--color-red-900: oklch(0.396 0.141 25.723);
--color-red-950: oklch(0.258 0.092 26.042);
}
@source inline("bg-red-{50,{100..900..100},950}");
@tailwind utilities;
`,
{ base: '/root' },
)
expect(build([])).toMatchInlineSnapshot(`
":root, :host {
--color-red-50: oklch(0.971 0.013 17.38);
--color-red-100: oklch(0.936 0.032 17.717);
--color-red-200: oklch(0.885 0.062 18.334);
--color-red-300: oklch(0.808 0.114 19.571);
--color-red-400: oklch(0.704 0.191 22.216);
--color-red-500: oklch(0.637 0.237 25.331);
--color-red-600: oklch(0.577 0.245 27.325);
--color-red-700: oklch(0.505 0.213 27.518);
--color-red-800: oklch(0.444 0.177 26.899);
--color-red-900: oklch(0.396 0.141 25.723);
--color-red-950: oklch(0.258 0.092 26.042);
}
.bg-red-50 {
background-color: var(--color-red-50);
}
.bg-red-100 {
background-color: var(--color-red-100);
}
.bg-red-200 {
background-color: var(--color-red-200);
}
.bg-red-300 {
background-color: var(--color-red-300);
}
.bg-red-400 {
background-color: var(--color-red-400);
}
.bg-red-500 {
background-color: var(--color-red-500);
}
.bg-red-600 {
background-color: var(--color-red-600);
}
.bg-red-700 {
background-color: var(--color-red-700);
}
.bg-red-800 {
background-color: var(--color-red-800);
}
.bg-red-900 {
background-color: var(--color-red-900);
}
.bg-red-950 {
background-color: var(--color-red-950);
}
"
`)
})
test('adds multiple inline sources separated by spaces', async () => {
let { build } = await compile(
css`
@theme {
--color-red-100: oklch(0.936 0.032 17.717);
--color-red-200: oklch(0.885 0.062 18.334);
}
@source inline("block bg-red-{100..200..100}");
@tailwind utilities;
`,
{ base: '/root' },
)
expect(build([])).toMatchInlineSnapshot(`
":root, :host {
--color-red-100: oklch(0.936 0.032 17.717);
--color-red-200: oklch(0.885 0.062 18.334);
}
.block {
display: block;
}
.bg-red-100 {
background-color: var(--color-red-100);
}
.bg-red-200 {
background-color: var(--color-red-200);
}
"
`)
})
test('ignores invalid inline candidates', async () => {
let { build } = await compile(
css`
@source inline("my-cucumber");
@tailwind utilities;
`,
{ base: '/root' },
)
expect(build([])).toMatchInlineSnapshot(`""`)
})
test('can be negated', async () => {
let { build } = await compile(
css`
@theme {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
@source not inline("container");
@tailwind utilities;
`,
{ base: '/root' },
)
expect(build(['container'])).toMatchInlineSnapshot(`""`)
})
test('applies brace expansion to negated sources', async () => {
let { build } = await compile(
css`
@theme {
--color-red-50: oklch(0.971 0.013 17.38);
--color-red-100: oklch(0.936 0.032 17.717);
--color-red-200: oklch(0.885 0.062 18.334);
--color-red-300: oklch(0.808 0.114 19.571);
--color-red-400: oklch(0.704 0.191 22.216);
--color-red-500: oklch(0.637 0.237 25.331);
--color-red-600: oklch(0.577 0.245 27.325);
--color-red-700: oklch(0.505 0.213 27.518);
--color-red-800: oklch(0.444 0.177 26.899);
--color-red-900: oklch(0.396 0.141 25.723);
--color-red-950: oklch(0.258 0.092 26.042);
}
@source not inline("bg-red-{50,{100..900..100},950}");
@tailwind utilities;
`,
{ base: '/root' },
)
expect(build(['bg-red-500', 'bg-red-700'])).toMatchInlineSnapshot(`""`)
})
})
})
describe('@custom-variant', () => {

View File

@ -26,8 +26,10 @@ import { applyVariant, compileCandidates } from './compile'
import { substituteFunctions } from './css-functions'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { enableSourceInline } from './feature-flags'
import { Theme, ThemeOptions } from './theme'
import { createCssUtility } from './utilities'
import { expand } from './utils/brace-expansion'
import { escape, unescape } from './utils/escape'
import { segment } from './utils/segment'
import { compoundsForSelectors, IS_VALID_VARIANT_NAME } from './variants'
@ -127,6 +129,8 @@ async function parseCss(
let utilitiesNode = null as AtRule | null
let variantNodes: AtRule[] = []
let globs: { base: string; pattern: string }[] = []
let inlineCandidates: string[] = []
let ignoredCandidates: string[] = []
let root = null as Root
// Handle at-rules
@ -208,7 +212,22 @@ async function parseCss(
throw new Error('`@source` cannot be nested.')
}
let not = false
let inline = false
let path = node.params
if (enableSourceInline) {
if (path[0] === 'n' && path.startsWith('not ')) {
not = true
path = path.slice(4)
}
if (path[0] === 'i' && path.startsWith('inline(')) {
inline = true
path = path.slice(7, -1)
}
}
if (
(path[0] === '"' && path[path.length - 1] !== '"') ||
(path[0] === "'" && path[path.length - 1] !== "'") ||
@ -216,7 +235,20 @@ async function parseCss(
) {
throw new Error('`@source` paths must be quoted.')
}
globs.push({ base: context.base as string, pattern: path.slice(1, -1) })
let source = path.slice(1, -1)
if (enableSourceInline && inline) {
let destination = not ? ignoredCandidates : inlineCandidates
let sources = segment(source, ' ')
for (let source of sources) {
for (let candidate of expand(source)) {
destination.push(candidate)
}
}
} else {
globs.push({ base: context.base as string, pattern: source })
}
replaceWith([])
return
}
@ -505,6 +537,12 @@ async function parseCss(
designSystem.important = important
}
if (ignoredCandidates.length > 0) {
for (let candidate of ignoredCandidates) {
designSystem.invalidCandidates.add(candidate)
}
}
// Apply hooks from backwards compatibility layer. This function takes a lot
// of random arguments because it really just needs access to "the world" to
// do whatever ungodly things it needs to do to make things backwards
@ -603,6 +641,7 @@ async function parseCss(
root,
utilitiesNode,
features,
inlineCandidates,
}
}
@ -615,7 +654,8 @@ export async function compileAst(
features: Features
build(candidates: string[]): AstNode[]
}> {
let { designSystem, ast, globs, root, utilitiesNode, features } = await parseCss(input, opts)
let { designSystem, ast, globs, root, utilitiesNode, features, inlineCandidates } =
await parseCss(input, opts)
if (process.env.NODE_ENV !== 'test') {
ast.unshift(comment(`! tailwindcss v${version} | MIT License | https://tailwindcss.com `))
@ -632,6 +672,14 @@ export async function compileAst(
let allValidCandidates = new Set<string>()
let compiled = null as AstNode[] | null
let previousAstNodeCount = 0
let defaultDidChange = false
for (let candidate of inlineCandidates) {
if (!designSystem.invalidCandidates.has(candidate)) {
allValidCandidates.add(candidate)
defaultDidChange = true
}
}
return {
globs,
@ -647,7 +695,8 @@ export async function compileAst(
return compiled
}
let didChange = false
let didChange = defaultDidChange
defaultDidChange = false
// Add all new candidates unless we know that they are invalid.
let prevSize = allValidCandidates.size

View File

@ -0,0 +1,14 @@
// import braces from 'braces'
import { bench } from 'vitest'
import { expand } from './brace-expansion'
const PATTERN =
'{{xs,sm,md,lg}:,}{border-{x,y,t,r,b,l,s,e},bg,text,cursor,accent}-{{red,orange,amber,yellow,lime,green,emerald,teal,cyan,sky,blue,indigo,violet,purple,fuchsia,pink,rose,slate,gray,zinc,neutral,stone}-{50,{100..900..100},950},black,white}{,/{0..100}}'
// bench('braces', () => {
// void braces.expand(PATTERN)
// })
bench('./brace-expansion', () => {
void expand(PATTERN)
})

View File

@ -0,0 +1,80 @@
import { describe, expect, test } from 'vitest'
import { expand } from './brace-expansion'
describe('expand(…)', () => {
test.each([
['a/b/c', ['a/b/c']],
// Groups
['a/{x,y,z}/b', ['a/x/b', 'a/y/b', 'a/z/b']],
['{a,b}/{x,y}', ['a/x', 'a/y', 'b/x', 'b/y']],
['{{xs,sm,md,lg}:,}hidden', ['xs:hidden', 'sm:hidden', 'md:hidden', 'lg:hidden', 'hidden']],
// Numeric ranges
['a/{0..5}/b', ['a/0/b', 'a/1/b', 'a/2/b', 'a/3/b', 'a/4/b', 'a/5/b']],
['a/{-5..0}/b', ['a/-5/b', 'a/-4/b', 'a/-3/b', 'a/-2/b', 'a/-1/b', 'a/0/b']],
['a/{0..-5}/b', ['a/0/b', 'a/-1/b', 'a/-2/b', 'a/-3/b', 'a/-4/b', 'a/-5/b']],
// Numeric range with padding
['a/{00..05}/b', ['a/00/b', 'a/01/b', 'a/02/b', 'a/03/b', 'a/04/b', 'a/05/b']],
[
'a{001..9}b',
['a001b', 'a002b', 'a003b', 'a004b', 'a005b', 'a006b', 'a007b', 'a008b', 'a009b'],
],
// Numeric range with step
['a/{0..5..2}/b', ['a/0/b', 'a/2/b', 'a/4/b']],
[
'bg-red-{100..900..100}',
[
'bg-red-100',
'bg-red-200',
'bg-red-300',
'bg-red-400',
'bg-red-500',
'bg-red-600',
'bg-red-700',
'bg-red-800',
'bg-red-900',
],
],
// Nested braces
['a{b,c,/{x,y}}/e', ['ab/e', 'ac/e', 'a/x/e', 'a/y/e']],
['a{b,c,/{x,y},{z,w}}/e', ['ab/e', 'ac/e', 'a/x/e', 'a/y/e', 'az/e', 'aw/e']],
['a{b,c,/{x,y},{0..2}}/e', ['ab/e', 'ac/e', 'a/x/e', 'a/y/e', 'a0/e', 'a1/e', 'a2/e']],
[
'bg-red-{50,{100..900..100},950}',
[
'bg-red-50',
'bg-red-100',
'bg-red-200',
'bg-red-300',
'bg-red-400',
'bg-red-500',
'bg-red-600',
'bg-red-700',
'bg-red-800',
'bg-red-900',
'bg-red-950',
],
],
// Should not try to expand ranges with decimals
['{1.1..2.2}', ['1.1..2.2']],
])('should expand %s', (input, expected) => {
expect(expand(input).sort()).toEqual(expected.sort())
})
test('throws on unbalanced braces', () => {
expect(() => expand('a{b,c{d,e},{f,g}h}x{y,z')).toThrowErrorMatchingInlineSnapshot(
`[Error: The pattern \`x{y,z\` is not balanced.]`,
)
})
test('throws when step is set to zero', () => {
expect(() => expand('a{0..5..0}/b')).toThrowErrorMatchingInlineSnapshot(
`[Error: Step cannot be zero in sequence expansion.]`,
)
})
})

View File

@ -0,0 +1,103 @@
import { segment } from './segment'
const NUMERICAL_RANGE = /^(-?\d+)\.\.(-?\d+)(?:\.\.(-?\d+))?$/
export function expand(pattern: string): string[] {
let index = pattern.indexOf('{')
if (index === -1) return [pattern]
let result: string[] = []
let pre = pattern.slice(0, index)
let rest = pattern.slice(index)
// Find the matching closing brace
let depth = 0
let endIndex = rest.lastIndexOf('}')
for (let i = 0; i < rest.length; i++) {
let char = rest[i]
if (char === '{') {
depth++
} else if (char === '}') {
depth--
if (depth === 0) {
endIndex = i
break
}
}
}
if (endIndex === -1) {
throw new Error(`The pattern \`${pattern}\` is not balanced.`)
}
let inside = rest.slice(1, endIndex)
let post = rest.slice(endIndex + 1)
let parts: string[]
if (isSequence(inside)) {
parts = expandSequence(inside)
} else {
parts = segment(inside, ',')
}
parts = parts.flatMap((part) => expand(part))
let expandedTail = expand(post)
for (let tail of expandedTail) {
for (let part of parts) {
result.push(pre + part + tail)
}
}
return result
}
function isSequence(str: string): boolean {
return NUMERICAL_RANGE.test(str)
}
/**
* Expands a sequence string like "01..20" (optionally with a step).
*/
function expandSequence(seq: string): string[] {
let seqMatch = seq.match(NUMERICAL_RANGE)
if (!seqMatch) {
return [seq]
}
let [, start, end, stepStr] = seqMatch
let step = stepStr ? parseInt(stepStr, 10) : undefined
let result: string[] = []
if (/^-?\d+$/.test(start) && /^-?\d+$/.test(end)) {
let startNum = parseInt(start, 10)
let endNum = parseInt(end, 10)
// Determine padding length (if any) but don't count the sign as length
let padLength = Math.max(start.replace(/^-/, '').length, end.replace(/^-/, '').length)
if (step === undefined) {
step = startNum <= endNum ? 1 : -1
}
if (step === 0) {
throw new Error('Step cannot be zero in sequence expansion.')
}
if (step > 0) {
for (let i = startNum; i <= endNum; i += step) {
let numStr = i.toString()
if (numStr.length < padLength) {
numStr = numStr.padStart(padLength, '0')
}
result.push(numStr)
}
} else {
for (let i = startNum; i >= endNum; i += step) {
let numStr = i.toString()
if (numStr.length < padLength) {
numStr = numStr.padStart(padLength, '0')
}
result.push(numStr)
}
}
}
return result
}