diff --git a/oxide/crates/core/src/lib.rs b/oxide/crates/core/src/lib.rs index 87327f39a..6f04adae0 100644 --- a/oxide/crates/core/src/lib.rs +++ b/oxide/crates/core/src/lib.rs @@ -433,9 +433,9 @@ fn read_all_files_sync(changed_content: Vec) -> Vec> { changed_content .into_iter() - .map(|c| match (c.file, c.content) { - (Some(file), None) => std::fs::read(file).unwrap(), - (None, Some(content)) => content.into_bytes(), + .filter_map(|c| match (c.file, c.content) { + (Some(file), None) => std::fs::read(file).ok(), + (None, Some(content)) => Some(content.into_bytes()), _ => Default::default(), }) .collect() diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index f3627ff91..7d007f308 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -1,12 +1,5 @@ import watcher from '@parcel/watcher' -import { - IO, - Parsing, - clearCache, - scanDir, - scanFiles, - type ChangedContent, -} from '@tailwindcss/oxide' +import { IO, Parsing, scanDir, scanFiles, type ChangedContent } from '@tailwindcss/oxide' import { existsSync } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' @@ -99,23 +92,41 @@ export async function handle(args: Result>) { args['--input'] ?? base, ) + let previous = { + css: '', + optimizedCss: '', + } + + async function write(css: string, args: Result>) { + let output = css + + // Optimize the output + if (args['--minify'] || args['--optimize']) { + if (css !== previous.css) { + let optimizedCss = optimizeCss(css, { + file: args['--input'] ?? 'input.css', + minify: args['--minify'] ?? false, + }) + previous.css = css + previous.optimizedCss = optimizedCss + output = optimizedCss + } else { + output = previous.optimizedCss + } + } + + // Write the output + if (args['--output']) { + await outputFile(args['--output'], output) + } else { + println(output) + } + } + // Compile the input let result = compile(input, candidates) - // Optimize the output - if (args['--minify'] || args['--optimize']) { - result = optimizeCss(result, { - file: args['--input'] ?? 'input.css', - minify: args['--minify'] ?? false, - }) - } - - // Write the output - if (args['--output']) { - await outputFile(args['--output'], result) - } else { - println(result) - } + await write(result.css, args) let end = process.hrtime.bigint() eprintln(header()) @@ -161,26 +172,14 @@ export async function handle(args: Result>) { // Re-compile the input let start = process.hrtime.bigint() + // Track the compiled CSS + let compiledCss = '' + // Scan the entire `base` directory for full rebuilds. if (rebuildStrategy === 'full') { - // Clear the cache because we need to re-scan the entire directory. - clearCache() - // Re-scan the directory to get the new `candidates`. candidates = scanDir({ base }).candidates - } - // Scan changed files only for incremental rebuilds. - else if (rebuildStrategy === 'incremental') { - let uniqueCandidates = new Set(candidates) - for (let candidate of scanFiles(changedFiles, IO.Sequential | Parsing.Sequential)) { - uniqueCandidates.add(candidate) - } - candidates = Array.from(uniqueCandidates) - } - - // Resolve the input - if (rebuildStrategy === 'full') { // Collect the new `input` and `cssImportPaths`. ;[input, cssImportPaths] = await handleImports( args['--input'] @@ -190,25 +189,19 @@ export async function handle(args: Result>) { `, args['--input'] ?? base, ) + + result = compile(input, candidates) + compiledCss = result.css } - // Compile the input - let result = compile(input, candidates) + // Scan changed files only for incremental rebuilds. + else if (rebuildStrategy === 'incremental') { + let newCandidates = scanFiles(changedFiles, IO.Sequential | Parsing.Sequential) - // Optimize the output - if (args['--minify'] || args['--optimize']) { - result = optimizeCss(result, { - file: args['--input'] ?? 'input.css', - minify: args['--minify'] ?? false, - }) + compiledCss = result.rebuild(newCandidates) } - // Write the output - if (args['--output']) { - await outputFile(args['--output'], result) - } else { - println(result) - } + await write(compiledCss, args) let end = process.hrtime.bigint() eprintln(`Done in ${formatDuration(end - start)}`) @@ -243,7 +236,9 @@ function handleImports( // Relevant specification: // - CSS Import Resolve: https://csstools.github.io/css-import-resolve/ - if (!input.includes('@import')) return [input, []] + if (!input.includes('@import')) { + return [input, [file]] + } return postcss() .use(atImport()) @@ -253,6 +248,8 @@ function handleImports( // Use `result.messages` to get the imported files. This also includes the // current file itself. - result.messages.filter((msg) => msg.type === 'postcss-import').map((msg) => msg.file), + [file].concat( + result.messages.filter((msg) => msg.type === 'dependency').map((msg) => msg.file), + ), ]) } diff --git a/packages/@tailwindcss-postcss/src/index.ts b/packages/@tailwindcss-postcss/src/index.ts index adab5cc56..0e200a6f1 100644 --- a/packages/@tailwindcss-postcss/src/index.ts +++ b/packages/@tailwindcss-postcss/src/index.ts @@ -1,8 +1,30 @@ import { scanDir } from '@tailwindcss/oxide' +import fs from 'fs' import postcss, { type AcceptedPlugin, type PluginCreator } from 'postcss' import postcssImport from 'postcss-import' import { compile, optimizeCss } from 'tailwindcss' +/** + * A Map that can generate default values for keys that don't exist. + * Generated default values are added to the map to avoid recomputation. + */ +class DefaultMap extends Map { + constructor(private factory: (key: T, self: DefaultMap) => V) { + super() + } + + get(key: T): V { + let value = super.get(key) + + if (value === undefined) { + value = this.factory(key, this) + this.set(key, value) + } + + return value + } +} + type PluginOptions = { // The base directory to scan for class candidates. base?: string @@ -15,13 +37,54 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { let base = opts.base ?? process.cwd() let optimize = opts.optimize ?? process.env.NODE_ENV === 'production' + let cache = new DefaultMap(() => { + return { + mtimes: new Map(), + rebuild: null as null | ReturnType['rebuild'], + previousCss: '', + previousOptimizedCss: '', + } + }) + return { - postcssPlugin: 'tailwindcss-v4', + postcssPlugin: '@tailwindcss/postcss', plugins: [ // We need to run `postcss-import` first to handle `@import` rules. postcssImport(), (root, result) => { + let from = result.opts.from ?? '' + let context = cache.get(from) + + let rebuildStrategy: 'full' | 'incremental' = 'incremental' + + // Bookkeeping — track file modification times to CSS files + { + let changedTime = fs.statSync(from, { throwIfNoEntry: false })?.mtimeMs ?? null + if (changedTime !== null) { + let prevTime = context.mtimes.get(from) + if (prevTime !== changedTime) { + rebuildStrategy = 'full' + context.mtimes.set(from, changedTime) + } + } else { + rebuildStrategy = 'full' + } + for (let message of result.messages) { + if (message.type === 'dependency') { + let file = message.file as string + let changedTime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null + if (changedTime !== null) { + let prevTime = context.mtimes.get(file) + if (prevTime !== changedTime) { + rebuildStrategy = 'full' + context.mtimes.set(file, changedTime) + } + } + } + } + } + let hasApply = false let hasTailwind = false @@ -40,22 +103,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { // Do nothing if neither `@tailwind` nor `@apply` is used if (!hasTailwind && !hasApply) return - function replaceCss(css: string) { - root.removeAll() - let output = css - if (optimize) { - output = optimizeCss(output, { - minify: typeof optimize === 'object' ? optimize.minify : false, - }) - } - root.append(postcss.parse(output, result.opts)) - } - - // No `@tailwind` means we don't have to look for candidates - if (!hasTailwind) { - replaceCss(compile(root.toString(), [])) - return - } + let css = '' // Look for candidates used to generate the CSS let { candidates, files, globs } = scanDir({ base, globs: true }) @@ -64,7 +112,7 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { for (let file of files) { result.messages.push({ type: 'dependency', - plugin: 'tailwindcss-v4', + plugin: '@tailwindcss/postcss', file, parent: result.opts.from, }) @@ -76,14 +124,49 @@ function tailwindcss(opts: PluginOptions = {}): AcceptedPlugin { for (let { base, glob } of globs) { result.messages.push({ type: 'dir-dependency', - plugin: 'tailwindcss-v4', + plugin: '@tailwindcss/postcss', dir: base, glob, parent: result.opts.from, }) } - replaceCss(compile(root.toString(), candidates)) + if (rebuildStrategy === 'full') { + if (hasTailwind) { + let compileResult = compile(root.toString(), candidates) + css = compileResult.css + context.rebuild = compileResult.rebuild.bind(compileResult) + } else { + css = compile(root.toString(), []).css + } + } else if (rebuildStrategy === 'incremental') { + css = context.rebuild!(candidates) + } + + function replaceCss(css: string) { + root.removeAll() + root.append(postcss.parse(css, result.opts)) + } + + // Replace CSS + if (css === context.previousCss) { + if (optimize) { + replaceCss(context.previousOptimizedCss) + } else { + replaceCss(css) + } + } else { + if (optimize) { + let optimizedCss = optimizeCss(css, { + minify: typeof optimize === 'object' ? optimize.minify : false, + }) + replaceCss(optimizedCss) + context.previousOptimizedCss = optimizedCss + } else { + replaceCss(css) + } + context.previousCss = css + } }, ], } diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index a4f66530c..e02f6e2cc 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -61,7 +61,7 @@ export default function tailwindcss(): Plugin[] { } function generateCss(css: string) { - return compile(css, Array.from(candidates)) + return compile(css, Array.from(candidates)).css } function generateOptimizedCss(css: string) { diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index d316c5411..dae7cf0e8 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -105,6 +105,13 @@ export function toCss(ast: AstNode[]) { return css } + if (node.selector === '@tailwind utilities') { + for (let child of node.nodes) { + css += stringify(child, depth) + } + return css + } + // Print at-rules without nodes with a `;` instead of an empty block. // // E.g.: diff --git a/packages/tailwindcss/src/candidate.bench.ts b/packages/tailwindcss/src/candidate.bench.ts index f10e95d09..f46dec7ec 100644 --- a/packages/tailwindcss/src/candidate.bench.ts +++ b/packages/tailwindcss/src/candidate.bench.ts @@ -1,9 +1,8 @@ import { scanDir } from '@tailwindcss/oxide' import { bench } from 'vitest' -import { parseCandidate, parseVariant } from './candidate' +import { parseCandidate } from './candidate' import { buildDesignSystem } from './design-system' import { Theme } from './theme' -import { DefaultMap } from './utils/default-map' // FOLDER=path/to/folder vitest bench const root = process.env.FOLDER || process.cwd() @@ -15,10 +14,6 @@ const designSystem = buildDesignSystem(new Theme()) bench('parseCandidate', () => { for (let candidate of result.candidates) { - parseCandidate( - candidate, - designSystem.utilities, - new DefaultMap((variant, map) => parseVariant(variant, designSystem.variants, map)), - ) + parseCandidate(candidate, designSystem) } }) diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index 672e8cbb4..17d1371b6 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest' -import { parseCandidate, parseVariant } from './candidate' +import { buildDesignSystem } from './design-system' +import { Theme } from './theme' import { Utilities } from './utilities' -import { DefaultMap } from './utils/default-map' import { Variants } from './variants' function run( @@ -11,11 +11,12 @@ function run( utilities ??= new Utilities() variants ??= new Variants() - let parsedVariants = new DefaultMap((variant, map) => { - return parseVariant(variant, variants!, map) - }) + let designSystem = buildDesignSystem(new Theme()) - return parseCandidate(candidate, utilities, parsedVariants) + designSystem.utilities = utilities + designSystem.variants = variants + + return designSystem.parseCandidate(candidate) } it('should skip unknown utilities', () => { diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index e3f6b92f2..03420e4d8 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -1,3 +1,4 @@ +import type { DesignSystem } from './design-system' import { decodeArbitraryValue } from './utils/decode-arbitrary-value' import { segment } from './utils/segment' @@ -206,14 +207,7 @@ export type Candidate = important: boolean } -export function parseCandidate( - input: string, - utilities: { - has: (value: string) => boolean - kind: (root: string) => Omit - }, - parsedVariants: { get: (value: string) => Variant | null }, -): Candidate | null { +export function parseCandidate(input: string, designSystem: DesignSystem): Candidate | null { // hover:focus:underline // ^^^^^ ^^^^^^ -> Variants // ^^^^^^^^^ -> Base @@ -228,7 +222,7 @@ export function parseCandidate( let parsedCandidateVariants: Variant[] = [] for (let variant of rawVariants) { - let parsedVariant = parsedVariants.get(variant) + let parsedVariant = designSystem.parseVariant(variant) if (parsedVariant === null) return null // Variants are applied left-to-right meaning that any representing pseudo- @@ -320,7 +314,7 @@ export function parseCandidate( base = base.slice(1) } - let [root, value] = findRoot(base, utilities) + let [root, value] = findRoot(base, designSystem.utilities) let modifierSegment: string | null = null @@ -335,13 +329,13 @@ export function parseCandidate( modifierSegment = rootModifierSegment // Try to find the root and value, without the modifier present - ;[root, value] = findRoot(rootWithoutModifier, utilities) + ;[root, value] = findRoot(rootWithoutModifier, designSystem.utilities) } // If there's no root, the candidate isn't a valid class and can be discarded. if (root === null) return null - let kind = utilities.kind(root) + let kind = designSystem.utilities.kind(root) if (kind === 'static') { if (value !== null) return null @@ -475,15 +469,7 @@ function parseModifier(modifier: string): CandidateModifier { } } -export function parseVariant( - variant: string, - variants: { - has: (value: string) => boolean - kind: (root: string) => Omit - compounds: (root: string) => boolean - }, - parsedVariants: { get: (value: string) => Variant | null }, -): Variant | null { +export function parseVariant(variant: string, designSystem: DesignSystem): Variant | null { // Arbitrary variants if (variant[0] === '[' && variant[variant.length - 1] === ']') { /** @@ -535,20 +521,20 @@ export function parseVariant( // - `group-hover/foo/bar` if (additionalModifier) return null - let [root, value] = findRoot(variantWithoutModifier, variants) + let [root, value] = findRoot(variantWithoutModifier, designSystem.variants) // Variant is invalid, therefore the candidate is invalid and we can skip // continue parsing it. if (root === null) return null - switch (variants.kind(root)) { + switch (designSystem.variants.kind(root)) { case 'static': { if (value !== null) return null return { kind: 'static', root, - compounds: variants.compounds(root), + compounds: designSystem.variants.compounds(root), } } @@ -564,7 +550,7 @@ export function parseVariant( kind: 'arbitrary', value: decodeArbitraryValue(value.slice(1, -1)), }, - compounds: variants.compounds(root), + compounds: designSystem.variants.compounds(root), } } @@ -573,14 +559,14 @@ export function parseVariant( root, modifier: modifier === null ? null : parseModifier(modifier), value: { kind: 'named', value }, - compounds: variants.compounds(root), + compounds: designSystem.variants.compounds(root), } } case 'compound': { if (value === null) return null - let subVariant = parsedVariants.get(value) + let subVariant = designSystem.parseVariant(value) if (subVariant === null) return null if (subVariant.compounds === false) return null @@ -589,7 +575,7 @@ export function parseVariant( root, modifier: modifier === null ? null : { kind: 'named', value: modifier }, variant: subVariant, - compounds: variants.compounds(root), + compounds: designSystem.variants.compounds(root), } } } diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index b91c19aca..d1dc7d0ee 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -1,120 +1,58 @@ import { rule, type AstNode, type Rule } from './ast' -import { parseCandidate, parseVariant, type Candidate, type Variant } from './candidate' +import { type Candidate, type Variant } from './candidate' import { type DesignSystem } from './design-system' import GLOBAL_PROPERTY_ORDER from './property-order' -import { DefaultMap } from './utils/default-map' import { escape } from './utils/escape' import type { Variants } from './variants' export function compileCandidates( - rawCandidates: string[], + rawCandidates: Iterable, designSystem: DesignSystem, { throwOnInvalidCandidate = false } = {}, ) { - // Ensure the candidates are sorted alphabetically - rawCandidates.sort() - let nodeSorting = new Map< AstNode, { properties: number[]; variants: bigint; candidate: string } >() let astNodes: AstNode[] = [] - - // A lazy map implementation that will return the variant if it exists. If it - // doesn't exist yet, the raw string variant will be parsed and added to the - // map. - let parsedVariants: DefaultMap = new DefaultMap((variant, map) => { - return parseVariant(variant, designSystem.variants, map) - }) - let candidates = new Map() // Parse candidates and variants for (let rawCandidate of rawCandidates) { - let candidate = parseCandidate(rawCandidate, designSystem.utilities, parsedVariants) + let candidate = designSystem.parseCandidate(rawCandidate) if (candidate === null) { if (throwOnInvalidCandidate) { throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`) } + designSystem.markInvalidCandidate(rawCandidate) continue // Bail, invalid candidate } candidates.set(candidate, rawCandidate) } // Sort the variants - let variants = Array.from(parsedVariants.values()).sort((a, z) => { + let variants = designSystem.getUsedVariants().sort((a, z) => { return designSystem.variants.compare(a, z) }) // Create the AST next: for (let [candidate, rawCandidate] of candidates) { - let nodes: AstNode[] = [] - - // Handle arbitrary properties - if (candidate.kind === 'arbitrary') { - let compileFn = designSystem.utilities.getArbitrary() - - // Build the node - let compiledNodes = compileFn(candidate) - if (compiledNodes === undefined) { - if (throwOnInvalidCandidate) { - throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`) - } - continue next + let astNode = designSystem.compileAstNodes(rawCandidate) + if (astNode === null) { + if (throwOnInvalidCandidate) { + throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`) } - - nodes = compiledNodes + designSystem.markInvalidCandidate(rawCandidate) + continue next } - // Handle named utilities - else if (candidate.kind === 'static' || candidate.kind === 'functional') { - // Safety: At this point it is safe to use TypeScript's non-null assertion - // operator because if the `candidate.root` didn't exist, `parseCandidate` - // would have returned `null` and we would have returned early resulting - // in not hitting this code path. - let { compileFn } = designSystem.utilities.get(candidate.root)! - - // Build the node - let compiledNodes = compileFn(candidate) - if (compiledNodes === undefined) { - if (throwOnInvalidCandidate) { - throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`) - } - continue next - } - - nodes = compiledNodes - } - - let propertySort = getPropertySort(nodes) - - if (candidate.important) { - applyImportant(nodes) - } - - let node: Rule = { - kind: 'rule', - selector: `.${escape(rawCandidate)}`, - nodes, - } + let { node, propertySort } = astNode + // Track the variant order which is a number with each bit representing a + // variant. This allows us to sort the rules based on the order of + // variants used. let variantOrder = 0n for (let variant of candidate.variants) { - let result = applyVariant(node, variant, designSystem.variants) - - // When the variant results in `null`, it means that the variant cannot be - // applied to the rule. Discard the candidate and continue to the next - // one. - if (result === null) { - if (throwOnInvalidCandidate) { - throw new Error(`Cannot apply unknown utility class: ${rawCandidate}`) - } - continue next - } - - // Track the variant order which is a number with each bit representing a - // variant. This allows us to sort the rules based on the order of - // variants used. variantOrder |= 1n << BigInt(variants.indexOf(variant)) } @@ -153,7 +91,9 @@ export function compileCandidates( // Sort by lowest property index first (aSorting.properties[offset] ?? Infinity) - (zSorting.properties[offset] ?? Infinity) || // Sort by most properties first, then by least properties - zSorting.properties.length - aSorting.properties.length + zSorting.properties.length - aSorting.properties.length || + // Sort alphabetically + (aSorting.candidate < zSorting.candidate ? -1 : 1) ) }) @@ -163,6 +103,65 @@ export function compileCandidates( } } +export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem) { + let candidate = designSystem.parseCandidate(rawCandidate) + if (candidate === null) return null + + let nodes: AstNode[] = [] + + // Handle arbitrary properties + if (candidate.kind === 'arbitrary') { + let compileFn = designSystem.utilities.getArbitrary() + + // Build the node + let compiledNodes = compileFn(candidate) + if (compiledNodes === undefined) return null + + nodes = compiledNodes + } + + // Handle named utilities + else if (candidate.kind === 'static' || candidate.kind === 'functional') { + // Safety: At this point it is safe to use TypeScript's non-null assertion + // operator because if the `candidate.root` didn't exist, `parseCandidate` + // would have returned `null` and we would have returned early resulting + // in not hitting this code path. + let { compileFn } = designSystem.utilities.get(candidate.root)! + + // Build the node + let compiledNodes = compileFn(candidate) + if (compiledNodes === undefined) return null + + nodes = compiledNodes + } + + let propertySort = getPropertySort(nodes) + + if (candidate.important) { + applyImportant(nodes) + } + + let node: Rule = { + kind: 'rule', + selector: `.${escape(rawCandidate)}`, + nodes, + } + + for (let variant of candidate.variants) { + let result = applyVariant(node, variant, designSystem.variants) + + // When the variant results in `null`, it means that the variant cannot be + // applied to the rule. Discard the candidate and continue to the next + // one. + if (result === null) return null + } + + return { + node, + propertySort, + } +} + export function applyVariant(node: Rule, variant: Variant, variants: Variants): null | void { if (variant.kind === 'arbitrary') { node.nodes = [rule(variant.selector, node.nodes)] diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 190c5e7e2..22277eda3 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -1,9 +1,11 @@ import { toCss } from './ast' -import { compileCandidates } from './compile' +import { parseCandidate, parseVariant } from './candidate' +import { compileAstNodes, compileCandidates } from './compile' import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' import { getClassOrder } from './sort' import type { Theme } from './theme' import { Utilities, createUtilities } from './utilities' +import { DefaultMap } from './utils/default-map' import { Variants, createVariants } from './variants' export type DesignSystem = { @@ -15,13 +17,31 @@ export type DesignSystem = { getClassOrder(classes: string[]): [string, bigint | null][] getClassList(): ClassEntry[] getVariants(): VariantEntry[] + + parseCandidate(candidate: string): ReturnType + parseVariant(variant: string): ReturnType + compileAstNodes(candidate: string): ReturnType + + getUsedVariants(): ReturnType[] + getAstNodeSize(): number + + markInvalidCandidate(candidate: string): void + isInvalidCandidate(candidate: string): boolean } export function buildDesignSystem(theme: Theme): DesignSystem { - return { + let utilities = createUtilities(theme) + let variants = createVariants(theme) + + let invalidRawCandidates = new Set() + let parsedVariants = new DefaultMap((variant) => parseVariant(variant, designSystem)) + let parsedCandidates = new DefaultMap((candidate) => parseCandidate(candidate, designSystem)) + let compiledAstNodes = new DefaultMap((candidate) => compileAstNodes(candidate, designSystem)) + + let designSystem: DesignSystem = { theme, - utilities: createUtilities(theme), - variants: createVariants(theme), + utilities, + variants, candidatesToCss(classes: string[]) { let result: (string | null)[] = [] @@ -47,5 +67,30 @@ export function buildDesignSystem(theme: Theme): DesignSystem { getVariants() { return getVariants(this) }, + + parseCandidate(candidate: string) { + return parsedCandidates.get(candidate) + }, + parseVariant(variant: string) { + return parsedVariants.get(variant) + }, + compileAstNodes(candidate: string) { + return compiledAstNodes.get(candidate) + }, + getUsedVariants() { + return Array.from(parsedVariants.values()) + }, + getAstNodeSize() { + return compiledAstNodes.size + }, + + markInvalidCandidate(candidate: string) { + invalidRawCandidates.add(candidate) + }, + isInvalidCandidate(candidate: string) { + return invalidRawCandidates.has(candidate) + }, } + + return designSystem } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index cf7025102..1488b4817 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -93,9 +93,7 @@ describe('compiling CSS', () => { .grid { display: grid; - } - - @tailwind utilities;" + }" `) }) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index d066a5a1f..cb5559a1b 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -6,7 +6,13 @@ import * as CSS from './css-parser' import { buildDesignSystem } from './design-system' import { Theme } from './theme' -export function compile(css: string, rawCandidates: string[]) { +export function compile( + css: string, + rawCandidates: string[], +): { + rebuild(candidates: string[]): string + css: string +} { let ast = CSS.parse(css) if (process.env.NODE_ENV !== 'test') { @@ -97,11 +103,17 @@ export function compile(css: string, rawCandidates: string[]) { let designSystem = buildDesignSystem(theme) + let tailwindUtilitiesNode: Rule | null = null + // Find `@tailwind utilities` and replace it with the actual generated utility // class CSS. - walk(ast, (node, { replaceWith }) => { + walk(ast, (node) => { if (node.kind === 'rule' && node.selector === '@tailwind utilities') { - replaceWith(compileCandidates(rawCandidates, designSystem).astNodes) + tailwindUtilitiesNode = node + + // Set the `@tailwind utilities` nodes, to the actual generated CSS + node.nodes = compileCandidates(rawCandidates, designSystem).astNodes + // Stop walking after finding `@tailwind utilities` to avoid walking all // of the generated CSS. This means `@tailwind utilities` can only appear // once per file but that's the intended usage at this point in time. @@ -162,7 +174,55 @@ export function compile(css: string, rawCandidates: string[]) { }) } - return toCss(ast) + // Track all valid candidates, these are the incoming `rawCandidate` that + // resulted in a generated AST Node. All the other `rawCandidates` are invalid + // and should be ignored. + let allValidCandidates = new Set() + for (let rawCandidate of rawCandidates) { + if (!designSystem.isInvalidCandidate(rawCandidate)) { + allValidCandidates.add(rawCandidate) + } + } + let compiledCss = toCss(ast) + + return { + rebuild(newRawCandidates: string[]) { + let didChange = false + + // Add all new candidates unless we know that they are invalid. + let prevSize = allValidCandidates.size + for (let candidate of newRawCandidates) { + if (!designSystem.isInvalidCandidate(candidate)) { + allValidCandidates.add(candidate) + didChange ||= allValidCandidates.size !== prevSize + } + } + + // If no new candidates were added, we can return the original CSS. This + // currently assumes that we only add new candidates and never remove any. + if (!didChange) { + return compiledCss + } + + if (tailwindUtilitiesNode) { + let previousAstNodeCount = designSystem.getAstNodeSize() + let newNodes = compileCandidates(allValidCandidates, designSystem).astNodes + + // If no new ast nodes were generated, then we can return the original + // CSS. This currently assumes that we only add new ast nodes and never + // remove any. + if (previousAstNodeCount === designSystem.getAstNodeSize()) { + return compiledCss + } + + tailwindUtilitiesNode.nodes = newNodes + compiledCss = toCss(ast) + } + + return compiledCss + }, + css: compiledCss, + } } export function optimizeCss( diff --git a/packages/tailwindcss/src/intellisense.ts b/packages/tailwindcss/src/intellisense.ts index 8b67641c6..4b1d814fb 100644 --- a/packages/tailwindcss/src/intellisense.ts +++ b/packages/tailwindcss/src/intellisense.ts @@ -1,8 +1,6 @@ import { decl, rule } from './ast' -import { parseVariant, type Variant } from './candidate' import { applyVariant } from './compile' import type { DesignSystem } from './design-system' -import { DefaultMap } from './utils/default-map' interface ClassMetadata { modifiers: string[] @@ -40,7 +38,7 @@ export function getClassList(design: DesignSystem): ClassEntry[] { } } - list.sort((a, b) => a[0].localeCompare(b[0])) + list.sort((a, b) => (a[0] === b[0] ? 0 : a[0] < b[0] ? -1 : 1)) return list } @@ -60,9 +58,6 @@ export interface VariantEntry { export function getVariants(design: DesignSystem) { let list: VariantEntry[] = [] - let parsedVariants = new DefaultMap((variant, map) => - parseVariant(variant, design.variants, map), - ) for (let [root, variant] of design.variants.entries()) { if (variant.kind === 'arbitrary') continue @@ -74,7 +69,7 @@ export function getVariants(design: DesignSystem) { if (value) name += `-${value}` if (modifier) name += `/${modifier}` - let variant = parsedVariants.get(name) + let variant = design.parseVariant(name) if (!variant) return [] diff --git a/packages/tailwindcss/src/test-utils/run.ts b/packages/tailwindcss/src/test-utils/run.ts index 8df6feab2..5ba052633 100644 --- a/packages/tailwindcss/src/test-utils/run.ts +++ b/packages/tailwindcss/src/test-utils/run.ts @@ -1,9 +1,9 @@ import { compile, optimizeCss } from '..' export function compileCss(css: string, candidates: string[] = []) { - return optimizeCss(compile(css, candidates)).trim() + return optimizeCss(compile(css, candidates).css).trim() } export function run(candidates: string[]) { - return optimizeCss(compile('@tailwind utilities;', candidates)).trim() + return optimizeCss(compile('@tailwind utilities;', candidates).css).trim() } diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 675fa6931..3df052b5a 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -1280,7 +1280,13 @@ test('supports', () => { expect( run(['supports-gap:grid', 'supports-[display:grid]:flex', 'supports-[selector(A_>_B)]:flex']), ).toMatchInlineSnapshot(` - "@supports (display: grid) { + "@supports (gap: var(--tw)) { + .supports-gap\\:grid { + display: grid; + } + } + + @supports (display: grid) { .supports-\\[display\\:grid\\]\\:flex { display: flex; } @@ -1290,12 +1296,6 @@ test('supports', () => { .supports-\\[selector\\(A_\\>_B\\)\\]\\:flex { display: flex; } - } - - @supports (gap: var(--tw)) { - .supports-gap\\:grid { - display: grid; - } }" `) }) @@ -1322,11 +1322,11 @@ test('not', () => { display: flex; } - .group-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:checked) *) { + .group-not-\\[\\:checked\\]\\:flex:is(:where(.group):not(:checked) *) { display: flex; } - .group-not-\\[\\:checked\\]\\:flex:is(:where(.group):not(:checked) *) { + .group-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):not(:checked) *) { display: flex; } @@ -1334,11 +1334,11 @@ test('not', () => { display: flex; } - .peer-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):not(:checked) ~ *) { + .peer-not-\\[\\:checked\\]\\:flex:is(:where(.peer):not(:checked) ~ *) { display: flex; } - .peer-not-\\[\\:checked\\]\\:flex:is(:where(.peer):not(:checked) ~ *) { + .peer-not-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):not(:checked) ~ *) { display: flex; }" `) @@ -1362,11 +1362,11 @@ test('has', () => { display: flex; } - .group-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *) { + .group-has-\\[\\:checked\\]\\:flex:is(:where(.group):has(:checked) *) { display: flex; } - .group-has-\\[\\:checked\\]\\:flex:is(:where(.group):has(:checked) *) { + .group-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *) { display: flex; } @@ -1374,11 +1374,11 @@ test('has', () => { display: flex; } - .peer-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):has(:checked) ~ *) { + .peer-has-\\[\\:checked\\]\\:flex:is(:where(.peer):has(:checked) ~ *) { display: flex; } - .peer-has-\\[\\:checked\\]\\:flex:is(:where(.peer):has(:checked) ~ *) { + .peer-has-\\[\\:checked\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name):has(:checked) ~ *) { display: flex; } @@ -1405,15 +1405,7 @@ test('aria', () => { 'peer-aria-checked/parent-name:flex', ]), ).toMatchInlineSnapshot(` - ".group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *) { - display: flex; - } - - .group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *) { - display: flex; - } - - .group-aria-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-checked="true"] *) { + ".group-aria-\\[modal\\]\\:flex:is(:where(.group)[aria-modal] *) { display: flex; } @@ -1421,7 +1413,11 @@ test('aria', () => { display: flex; } - .peer-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-modal] ~ *) { + .group-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-modal] *) { + display: flex; + } + + .group-aria-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name)[aria-checked="true"] *) { display: flex; } @@ -1429,20 +1425,24 @@ test('aria', () => { display: flex; } - .peer-aria-checked\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-checked="true"] ~ *) { - display: flex; - } - .peer-aria-checked\\:flex:is(:where(.peer)[aria-checked="true"] ~ *) { display: flex; } - .aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"] { + .peer-aria-\\[modal\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-modal] ~ *) { + display: flex; + } + + .peer-aria-checked\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[aria-checked="true"] ~ *) { display: flex; } .aria-checked\\:flex[aria-checked="true"] { display: flex; + } + + .aria-\\[invalid\\=spelling\\]\\:flex[aria-invalid="spelling"] { + display: flex; }" `) }) @@ -1460,15 +1460,11 @@ test('data', () => { 'peer-data-[disabled]/parent-name:flex', ]), ).toMatchInlineSnapshot(` - ".group-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-disabled] *) { + ".group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *) { display: flex; } - .group-data-\\[disabled\\]\\:flex:is(:where(.group)[data-disabled] *) { - display: flex; - } - - .peer-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-disabled] ~ *) { + .group-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name)[data-disabled] *) { display: flex; } @@ -1476,12 +1472,16 @@ test('data', () => { display: flex; } - .data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] { + .peer-data-\\[disabled\\]\\/parent-name\\:flex:is(:where(.peer\\/parent-name)[data-disabled] ~ *) { display: flex; } .data-disabled\\:flex[data-disabled] { display: flex; + } + + .data-\\[potato\\=salad\\]\\:flex[data-potato="salad"] { + display: flex; }" `) }) @@ -1567,14 +1567,14 @@ test('container queries', () => { --width-lg: 1024px; } - @container name (width < 1024px) { - .\\@max-lg\\/name\\:flex { + @container (width < 1024px) { + .\\@max-lg\\:flex { display: flex; } } - @container (width < 1024px) { - .\\@max-lg\\:flex { + @container name (width < 1024px) { + .\\@max-lg\\/name\\:flex { display: flex; } } @@ -1615,12 +1615,6 @@ test('container queries', () => { } } - @container name (width >= 1024px) { - .\\@lg\\/name\\:flex { - display: flex; - } - } - @container (width >= 1024px) { .\\@lg\\:flex { display: flex; @@ -1628,7 +1622,7 @@ test('container queries', () => { } @container name (width >= 1024px) { - .\\@min-lg\\/name\\:flex { + .\\@lg\\/name\\:flex { display: flex; } } @@ -1637,6 +1631,12 @@ test('container queries', () => { .\\@min-lg\\:flex { display: flex; } + } + + @container name (width >= 1024px) { + .\\@min-lg\\/name\\:flex { + display: flex; + } }" `) }) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 3e5138c62..3cc0919ad 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -94,7 +94,7 @@ export class Variants { if (z === null) return 1 if (a.kind === 'arbitrary' && z.kind === 'arbitrary') { - return a.selector.localeCompare(z.selector) + return a.selector < z.selector ? -1 : 1 } else if (a.kind === 'arbitrary') { return 1 } else if (z.kind === 'arbitrary') { @@ -114,7 +114,7 @@ export class Variants { let compareFn = this.compareFns.get(aOrder) if (compareFn === undefined) return 0 - return compareFn(a, z) + return compareFn(a, z) || (a.root < z.root ? -1 : 1) } keys() { @@ -469,7 +469,7 @@ export function createVariants(theme: Theme): Variants { let order = // Compare by bucket name - aBucket.localeCompare(zBucket) || + (aBucket === zBucket ? 0 : aBucket < zBucket ? -1 : 1) || // If bucket names are the same, compare by value (direction === 'asc' ? parseInt(aValue) - parseInt(zValue) @@ -489,7 +489,7 @@ export function createVariants(theme: Theme): Variants { // In this scenario, we want to alphabetically sort `calc(100%-1rem)` and // `calc(100%-2rem)` to make it deterministic. if (Number.isNaN(order)) { - return aValue.localeCompare(zValue) + return aValue < zValue ? -1 : 1 } return order diff --git a/packages/tailwindcss/tests/ui.spec.ts b/packages/tailwindcss/tests/ui.spec.ts index eae93402c..126b194fc 100644 --- a/packages/tailwindcss/tests/ui.spec.ts +++ b/packages/tailwindcss/tests/ui.spec.ts @@ -243,7 +243,7 @@ async function render(page: Page, content: string) { } `, scanFiles([{ content, extension: 'html' }], IO.Sequential | Parsing.Sequential), - ), + ).css, ), })