diff --git a/CHANGELOG.md b/CHANGELOG.md index 261ecb8e503f..635f62f7288f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/feature-flags.ts b/packages/tailwindcss/src/feature-flags.ts index d3278905e8d2..404353be2804 100644 --- a/packages/tailwindcss/src/feature-flags.ts +++ b/packages/tailwindcss/src/feature-flags.ts @@ -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' diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index c233541538d6..4779e6c18e65 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -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', () => { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 65d1379da1c6..ddcd17ae6565 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -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() 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 diff --git a/packages/tailwindcss/src/utils/brace-expansion.bench.ts b/packages/tailwindcss/src/utils/brace-expansion.bench.ts new file mode 100644 index 000000000000..6a0e3d8fd900 --- /dev/null +++ b/packages/tailwindcss/src/utils/brace-expansion.bench.ts @@ -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) +}) diff --git a/packages/tailwindcss/src/utils/brace-expansion.test.ts b/packages/tailwindcss/src/utils/brace-expansion.test.ts new file mode 100644 index 000000000000..f6d3d12f130a --- /dev/null +++ b/packages/tailwindcss/src/utils/brace-expansion.test.ts @@ -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.]`, + ) + }) +}) diff --git a/packages/tailwindcss/src/utils/brace-expansion.ts b/packages/tailwindcss/src/utils/brace-expansion.ts new file mode 100644 index 000000000000..95431d4c6704 --- /dev/null +++ b/packages/tailwindcss/src/utils/brace-expansion.ts @@ -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 +}