Skip to content

Commit 215f4f3

Browse files
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 <[email protected]>
1 parent ca408d0 commit 215f4f3

File tree

7 files changed

+430
-3
lines changed

7 files changed

+430
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- _Experimental_: Add `any-pointer-none`, `any-pointer-coarse`, and `any-pointer-fine` variants ([#16941](https://github.com/tailwindlabs/tailwindcss/pull/16941))
1717
- _Experimental_: Add `user-valid` and `user-invalid` variants ([#12370](https://github.com/tailwindlabs/tailwindcss/pull/12370))
1818
- _Experimental_: Add `wrap-anywhere`, `wrap-break-word`, and `wrap-normal` utilities ([#12128](https://github.com/tailwindlabs/tailwindcss/pull/12128))
19+
- _Experimental_: Add `@source inline(…)` ([#17147](https://github.com/tailwindlabs/tailwindcss/pull/17147))
1920

2021
### Fixed
2122

packages/tailwindcss/src/feature-flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export const enableDetailsContent = process.env.FEATURES_ENV !== 'stable'
22
export const enableInvertedColors = process.env.FEATURES_ENV !== 'stable'
33
export const enablePointerVariants = process.env.FEATURES_ENV !== 'stable'
44
export const enableScripting = process.env.FEATURES_ENV !== 'stable'
5+
export const enableSourceInline = process.env.FEATURES_ENV !== 'stable'
56
export const enableUserValid = process.env.FEATURES_ENV !== 'stable'
67
export const enableWrapAnywhere = process.env.FEATURES_ENV !== 'stable'

packages/tailwindcss/src/index.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3194,6 +3194,185 @@ describe('@source', () => {
31943194
{ pattern: './php/secr3t/smarty.php', base: '/root' },
31953195
])
31963196
})
3197+
3198+
describe('@source inline(…)', () => {
3199+
test('always includes the candidate', async () => {
3200+
let { build } = await compile(
3201+
css`
3202+
@source inline("underline");
3203+
@tailwind utilities;
3204+
`,
3205+
{ base: '/root' },
3206+
)
3207+
3208+
expect(build([])).toMatchInlineSnapshot(`
3209+
".underline {
3210+
text-decoration-line: underline;
3211+
}
3212+
"
3213+
`)
3214+
})
3215+
3216+
test('applies brace expansion', async () => {
3217+
let { build } = await compile(
3218+
css`
3219+
@theme {
3220+
--color-red-50: oklch(0.971 0.013 17.38);
3221+
--color-red-100: oklch(0.936 0.032 17.717);
3222+
--color-red-200: oklch(0.885 0.062 18.334);
3223+
--color-red-300: oklch(0.808 0.114 19.571);
3224+
--color-red-400: oklch(0.704 0.191 22.216);
3225+
--color-red-500: oklch(0.637 0.237 25.331);
3226+
--color-red-600: oklch(0.577 0.245 27.325);
3227+
--color-red-700: oklch(0.505 0.213 27.518);
3228+
--color-red-800: oklch(0.444 0.177 26.899);
3229+
--color-red-900: oklch(0.396 0.141 25.723);
3230+
--color-red-950: oklch(0.258 0.092 26.042);
3231+
}
3232+
@source inline("bg-red-{50,{100..900..100},950}");
3233+
@tailwind utilities;
3234+
`,
3235+
{ base: '/root' },
3236+
)
3237+
3238+
expect(build([])).toMatchInlineSnapshot(`
3239+
":root, :host {
3240+
--color-red-50: oklch(0.971 0.013 17.38);
3241+
--color-red-100: oklch(0.936 0.032 17.717);
3242+
--color-red-200: oklch(0.885 0.062 18.334);
3243+
--color-red-300: oklch(0.808 0.114 19.571);
3244+
--color-red-400: oklch(0.704 0.191 22.216);
3245+
--color-red-500: oklch(0.637 0.237 25.331);
3246+
--color-red-600: oklch(0.577 0.245 27.325);
3247+
--color-red-700: oklch(0.505 0.213 27.518);
3248+
--color-red-800: oklch(0.444 0.177 26.899);
3249+
--color-red-900: oklch(0.396 0.141 25.723);
3250+
--color-red-950: oklch(0.258 0.092 26.042);
3251+
}
3252+
.bg-red-50 {
3253+
background-color: var(--color-red-50);
3254+
}
3255+
.bg-red-100 {
3256+
background-color: var(--color-red-100);
3257+
}
3258+
.bg-red-200 {
3259+
background-color: var(--color-red-200);
3260+
}
3261+
.bg-red-300 {
3262+
background-color: var(--color-red-300);
3263+
}
3264+
.bg-red-400 {
3265+
background-color: var(--color-red-400);
3266+
}
3267+
.bg-red-500 {
3268+
background-color: var(--color-red-500);
3269+
}
3270+
.bg-red-600 {
3271+
background-color: var(--color-red-600);
3272+
}
3273+
.bg-red-700 {
3274+
background-color: var(--color-red-700);
3275+
}
3276+
.bg-red-800 {
3277+
background-color: var(--color-red-800);
3278+
}
3279+
.bg-red-900 {
3280+
background-color: var(--color-red-900);
3281+
}
3282+
.bg-red-950 {
3283+
background-color: var(--color-red-950);
3284+
}
3285+
"
3286+
`)
3287+
})
3288+
3289+
test('adds multiple inline sources separated by spaces', async () => {
3290+
let { build } = await compile(
3291+
css`
3292+
@theme {
3293+
--color-red-100: oklch(0.936 0.032 17.717);
3294+
--color-red-200: oklch(0.885 0.062 18.334);
3295+
}
3296+
@source inline("block bg-red-{100..200..100}");
3297+
@tailwind utilities;
3298+
`,
3299+
{ base: '/root' },
3300+
)
3301+
3302+
expect(build([])).toMatchInlineSnapshot(`
3303+
":root, :host {
3304+
--color-red-100: oklch(0.936 0.032 17.717);
3305+
--color-red-200: oklch(0.885 0.062 18.334);
3306+
}
3307+
.block {
3308+
display: block;
3309+
}
3310+
.bg-red-100 {
3311+
background-color: var(--color-red-100);
3312+
}
3313+
.bg-red-200 {
3314+
background-color: var(--color-red-200);
3315+
}
3316+
"
3317+
`)
3318+
})
3319+
3320+
test('ignores invalid inline candidates', async () => {
3321+
let { build } = await compile(
3322+
css`
3323+
@source inline("my-cucumber");
3324+
@tailwind utilities;
3325+
`,
3326+
{ base: '/root' },
3327+
)
3328+
3329+
expect(build([])).toMatchInlineSnapshot(`""`)
3330+
})
3331+
3332+
test('can be negated', async () => {
3333+
let { build } = await compile(
3334+
css`
3335+
@theme {
3336+
--breakpoint-sm: 40rem;
3337+
--breakpoint-md: 48rem;
3338+
--breakpoint-lg: 64rem;
3339+
--breakpoint-xl: 80rem;
3340+
--breakpoint-2xl: 96rem;
3341+
}
3342+
@source not inline("container");
3343+
@tailwind utilities;
3344+
`,
3345+
{ base: '/root' },
3346+
)
3347+
3348+
expect(build(['container'])).toMatchInlineSnapshot(`""`)
3349+
})
3350+
3351+
test('applies brace expansion to negated sources', async () => {
3352+
let { build } = await compile(
3353+
css`
3354+
@theme {
3355+
--color-red-50: oklch(0.971 0.013 17.38);
3356+
--color-red-100: oklch(0.936 0.032 17.717);
3357+
--color-red-200: oklch(0.885 0.062 18.334);
3358+
--color-red-300: oklch(0.808 0.114 19.571);
3359+
--color-red-400: oklch(0.704 0.191 22.216);
3360+
--color-red-500: oklch(0.637 0.237 25.331);
3361+
--color-red-600: oklch(0.577 0.245 27.325);
3362+
--color-red-700: oklch(0.505 0.213 27.518);
3363+
--color-red-800: oklch(0.444 0.177 26.899);
3364+
--color-red-900: oklch(0.396 0.141 25.723);
3365+
--color-red-950: oklch(0.258 0.092 26.042);
3366+
}
3367+
@source not inline("bg-red-{50,{100..900..100},950}");
3368+
@tailwind utilities;
3369+
`,
3370+
{ base: '/root' },
3371+
)
3372+
3373+
expect(build(['bg-red-500', 'bg-red-700'])).toMatchInlineSnapshot(`""`)
3374+
})
3375+
})
31973376
})
31983377

31993378
describe('@custom-variant', () => {

packages/tailwindcss/src/index.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ import { applyVariant, compileCandidates } from './compile'
2626
import { substituteFunctions } from './css-functions'
2727
import * as CSS from './css-parser'
2828
import { buildDesignSystem, type DesignSystem } from './design-system'
29+
import { enableSourceInline } from './feature-flags'
2930
import { Theme, ThemeOptions } from './theme'
3031
import { createCssUtility } from './utilities'
32+
import { expand } from './utils/brace-expansion'
3133
import { escape, unescape } from './utils/escape'
3234
import { segment } from './utils/segment'
3335
import { compoundsForSelectors, IS_VALID_VARIANT_NAME } from './variants'
@@ -127,6 +129,8 @@ async function parseCss(
127129
let utilitiesNode = null as AtRule | null
128130
let variantNodes: AtRule[] = []
129131
let globs: { base: string; pattern: string }[] = []
132+
let inlineCandidates: string[] = []
133+
let ignoredCandidates: string[] = []
130134
let root = null as Root
131135

132136
// Handle at-rules
@@ -208,15 +212,43 @@ async function parseCss(
208212
throw new Error('`@source` cannot be nested.')
209213
}
210214

215+
let not = false
216+
let inline = false
211217
let path = node.params
218+
219+
if (enableSourceInline) {
220+
if (path[0] === 'n' && path.startsWith('not ')) {
221+
not = true
222+
path = path.slice(4)
223+
}
224+
225+
if (path[0] === 'i' && path.startsWith('inline(')) {
226+
inline = true
227+
path = path.slice(7, -1)
228+
}
229+
}
230+
212231
if (
213232
(path[0] === '"' && path[path.length - 1] !== '"') ||
214233
(path[0] === "'" && path[path.length - 1] !== "'") ||
215234
(path[0] !== "'" && path[0] !== '"')
216235
) {
217236
throw new Error('`@source` paths must be quoted.')
218237
}
219-
globs.push({ base: context.base as string, pattern: path.slice(1, -1) })
238+
239+
let source = path.slice(1, -1)
240+
241+
if (enableSourceInline && inline) {
242+
let destination = not ? ignoredCandidates : inlineCandidates
243+
let sources = segment(source, ' ')
244+
for (let source of sources) {
245+
for (let candidate of expand(source)) {
246+
destination.push(candidate)
247+
}
248+
}
249+
} else {
250+
globs.push({ base: context.base as string, pattern: source })
251+
}
220252
replaceWith([])
221253
return
222254
}
@@ -505,6 +537,12 @@ async function parseCss(
505537
designSystem.important = important
506538
}
507539

540+
if (ignoredCandidates.length > 0) {
541+
for (let candidate of ignoredCandidates) {
542+
designSystem.invalidCandidates.add(candidate)
543+
}
544+
}
545+
508546
// Apply hooks from backwards compatibility layer. This function takes a lot
509547
// of random arguments because it really just needs access to "the world" to
510548
// do whatever ungodly things it needs to do to make things backwards
@@ -603,6 +641,7 @@ async function parseCss(
603641
root,
604642
utilitiesNode,
605643
features,
644+
inlineCandidates,
606645
}
607646
}
608647

@@ -615,7 +654,8 @@ export async function compileAst(
615654
features: Features
616655
build(candidates: string[]): AstNode[]
617656
}> {
618-
let { designSystem, ast, globs, root, utilitiesNode, features } = await parseCss(input, opts)
657+
let { designSystem, ast, globs, root, utilitiesNode, features, inlineCandidates } =
658+
await parseCss(input, opts)
619659

620660
if (process.env.NODE_ENV !== 'test') {
621661
ast.unshift(comment(`! tailwindcss v${version} | MIT License | https://tailwindcss.com `))
@@ -632,6 +672,14 @@ export async function compileAst(
632672
let allValidCandidates = new Set<string>()
633673
let compiled = null as AstNode[] | null
634674
let previousAstNodeCount = 0
675+
let defaultDidChange = false
676+
677+
for (let candidate of inlineCandidates) {
678+
if (!designSystem.invalidCandidates.has(candidate)) {
679+
allValidCandidates.add(candidate)
680+
defaultDidChange = true
681+
}
682+
}
635683

636684
return {
637685
globs,
@@ -647,7 +695,8 @@ export async function compileAst(
647695
return compiled
648696
}
649697

650-
let didChange = false
698+
let didChange = defaultDidChange
699+
defaultDidChange = false
651700

652701
// Add all new candidates unless we know that they are invalid.
653702
let prevSize = allValidCandidates.size
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// import braces from 'braces'
2+
import { bench } from 'vitest'
3+
import { expand } from './brace-expansion'
4+
5+
const PATTERN =
6+
'{{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}}'
7+
8+
// bench('braces', () => {
9+
// void braces.expand(PATTERN)
10+
// })
11+
12+
bench('./brace-expansion', () => {
13+
void expand(PATTERN)
14+
})

0 commit comments

Comments
 (0)