From aaaefe8b5df0a1e985190880c8067b4b3fe08421 Mon Sep 17 00:00:00 2001 From: Aaron Tinio Date: Mon, 16 Mar 2026 23:00:11 +0800 Subject: [PATCH 1/3] Add `--stream` flag to `canonicalize` subcommand (#19796) ## Summary - Adds `--stream` flag to `tailwindcss canonicalize` that reads candidate groups from stdin line by line and writes canonicalized results to stdout - Keeps the design system loaded across requests, making it suitable as a long-running sidecar process - Empty lines pass through, keeping request/response pairs aligned ## Motivation Non-JS tools (formatters, editor plugins, etc.) currently have no lightweight way to canonicalize Tailwind classes. The existing batch mode works for one-off use, but tools that need to canonicalize repeatedly pay the cost of loading the design system each time. With `--stream`, a tool can start `tailwindcss canonicalize --stream` once and send candidate groups over stdin as needed: ```sh $ echo -e "py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2" | tailwindcss canonicalize --stream p-3 m-2 ``` Related discussion: https://github.com/tailwindlabs/tailwindcss/discussions/19736 --------- Co-authored-by: Robin Malfait --- .../canonicalize/canonicalize.test.ts | 60 +++++++- .../src/commands/canonicalize/index.ts | 136 ++++++++++++++---- 2 files changed, 170 insertions(+), 26 deletions(-) diff --git a/packages/@tailwindcss-cli/src/commands/canonicalize/canonicalize.test.ts b/packages/@tailwindcss-cli/src/commands/canonicalize/canonicalize.test.ts index c4ffed59d57d..4392380a574d 100644 --- a/packages/@tailwindcss-cli/src/commands/canonicalize/canonicalize.test.ts +++ b/packages/@tailwindcss-cli/src/commands/canonicalize/canonicalize.test.ts @@ -1,7 +1,8 @@ import path from 'node:path' +import { PassThrough, Readable } from 'node:stream' import { fileURLToPath } from 'node:url' import { describe, expect, test } from 'vitest' -import { runCommandLine } from '.' +import { runCommandLine, streamStdin } from '.' import { normalizeWindowsSeparators } from '../../utils/test-helpers' let css = normalizeWindowsSeparators( @@ -114,4 +115,61 @@ describe('runCommandLine', { timeout: 30_000 }, () => { expect(result.stderr).toBe('No candidate groups provided') expect(result.stdout).toContain('Usage:') }) + + test('streams canonicalized output line by line', async () => { + let input = Readable.from('py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2\n') + let { stream: output, collect: collectOutput } = createOutput() + + await streamStdin({ css, cwd: path.dirname(css), format: 'text', input, output }) + + expect(collectOutput()).toBe('p-3\nm-2\n') + }) + + test('streams empty lines as empty lines', async () => { + let input = Readable.from('py-3 p-1 px-3\n\nmt-2 mr-2 mb-2 ml-2\n') + let { stream: output, collect: collectOutput } = createOutput() + + await streamStdin({ css, cwd: path.dirname(css), format: 'text', input, output }) + + expect(collectOutput()).toBe('p-3\n\nm-2\n') + }) + + test('streams json output when requested', async () => { + let input = Readable.from('py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2\n') + let { stream: output, collect: collectOutput } = createOutput() + + await streamStdin({ css, cwd: path.dirname(css), format: 'json', input, output }) + + expect(JSON.parse(collectOutput())).toEqual([ + { + input: 'py-3 p-1 px-3', + output: 'p-3', + changed: true, + }, + { + input: 'mt-2 mr-2 mb-2 ml-2', + output: 'm-2', + changed: true, + }, + ]) + }) + + test('streams jsonl output when requested', async () => { + let input = Readable.from('py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2\n') + let { stream: output, collect: collectOutput } = createOutput() + + await streamStdin({ css, cwd: path.dirname(css), format: 'jsonl', input, output }) + + expect(collectOutput()).toBe( + '{"input":"py-3 p-1 px-3","output":"p-3","changed":true}\n' + + '{"input":"mt-2 mr-2 mb-2 ml-2","output":"m-2","changed":true}\n', + ) + }) }) + +function createOutput() { + let stream = new PassThrough() + let chunks: Buffer[] = [] + stream.on('data', (chunk) => chunks.push(chunk)) + return { stream, collect: () => Buffer.concat(chunks).toString() } +} diff --git a/packages/@tailwindcss-cli/src/commands/canonicalize/index.ts b/packages/@tailwindcss-cli/src/commands/canonicalize/index.ts index 7748d1599331..5aecfe51edad 100644 --- a/packages/@tailwindcss-cli/src/commands/canonicalize/index.ts +++ b/packages/@tailwindcss-cli/src/commands/canonicalize/index.ts @@ -1,6 +1,8 @@ import { __unstable__loadDesignSystem } from '@tailwindcss/node' import fs from 'node:fs/promises' import path from 'node:path' +import { createInterface } from 'node:readline' +import type { Readable, Writable } from 'node:stream' import { compare } from '../../../../tailwindcss/src/utils/compare' import { segment } from '../../../../tailwindcss/src/utils/segment' import { args, type Arg } from '../../utils/args' @@ -37,6 +39,10 @@ function usageWithCss() { return 'tailwindcss canonicalize --css input.css [classes...]' } +function usageWithStream() { + return 'tailwindcss canonicalize --stream [--css input.css]' +} + export function options() { return { '--css': { @@ -50,6 +56,11 @@ export function options() { default: 'text', values: ['text', 'json', 'jsonl'], }, + '--stream': { + type: 'boolean', + description: 'Read candidate groups from stdin line by line and write results to stdout', + default: false, + }, } satisfies Arg } @@ -78,15 +89,27 @@ export async function runCommandLine({ argv, ) + let format = parseFormat(flags['--format'] ?? 'text') + if ((stdoutIsTTY && argv.length === 0) || flags['--help']) { return { exitCode: 0, - stdout: helpMessage(), + stdout: helpMessage() ?? '', stderr: '', } } - let format = parseFormat(flags['--format']) + if (flags['--stream']) { + await streamStdin({ + css: flags['--css'], + cwd, + format, + input: process.stdin, + output: process.stdout, + }) + return { exitCode: 0, stdout: '', stderr: '' } + } + let inputs = flags._.length > 0 ? flags._ : await readCandidateGroups({ stdin, stdinIsTTY }) if (inputs.length === 0) { @@ -113,6 +136,60 @@ export async function runCommandLine({ } } +export async function streamStdin({ + css: cssFile, + cwd, + format, + input, + output, +}: { + css: string | null + cwd: string + format: OutputFormat + input: Readable + output: Writable +}): Promise { + let designSystem = await loadDesignSystem(cssFile, cwd) + let rl = createInterface({ input }) + let first = true + + if (format === 'json') { + output.write('[') + } + + for await (let line of rl) { + let result = createCandidateGroupResult(designSystem, line) + + switch (format) { + case 'text': { + output.write(result.output + '\n') + break + } + + case 'jsonl': { + output.write(JSON.stringify(result) + '\n') + break + } + + case 'json': { + if (first) { + output.write('\n') + } else { + output.write(',\n') + } + + output.write(indent(JSON.stringify(result, null, 2), 2)) + first = false + break + } + } + } + + if (format === 'json') { + output.write(first ? ']' : '\n]') + } +} + export function readCandidateGroups({ stdin, stdinIsTTY, @@ -155,24 +232,7 @@ export async function processCandidateGroups({ }): Promise { let designSystem = await loadDesignSystem(css, cwd) - return inputs.map((input) => { - let originalCandidates = splitCandidates(input) - let outputCandidates = sortCandidates( - designSystem, - designSystem.canonicalizeCandidates(originalCandidates, { - collapse: true, - logicalToPhysical: true, - }), - ) - - let output = outputCandidates.join(' ') - - return { - input, - output, - changed: output !== input, - } - }) + return inputs.map((input) => createCandidateGroupResult(designSystem, input)) } export function formatCandidateResults(results: CandidateGroupResult[], format: OutputFormat) { @@ -189,7 +249,7 @@ export function formatCandidateResults(results: CandidateGroupResult[], format: function helpMessage() { return help({ render: false, - usage: [usage(), usageWithCss()], + usage: [usage(), usageWithCss(), usageWithStream()], options: { ...options(), ...sharedOptions, @@ -232,7 +292,7 @@ function parseFormat(input: string): OutputFormat { function usageError(message: string): RunCommandLineResult { return { exitCode: 1, - stdout: helpMessage(), + stdout: helpMessage() ?? '', stderr: message, } } @@ -246,11 +306,37 @@ function splitCandidates(input: string) { .filter((candidate) => candidate.length > 0) } -function sortCandidates( +function canonicalize( designSystem: Awaited>, - candidates: string[], + input: string, ) { - return defaultSort(designSystem.getClassOrder(candidates)) + let candidates = splitCandidates(input) + candidates = designSystem.canonicalizeCandidates(candidates, { + collapse: true, + logicalToPhysical: true, + }) + return defaultSort(designSystem.getClassOrder(candidates)).join(' ') +} + +function createCandidateGroupResult( + designSystem: Awaited>, + input: string, +): CandidateGroupResult { + let output = canonicalize(designSystem, input) + + return { + input, + output, + changed: output !== input, + } +} + +function indent(input: string, size: number) { + let prefix = ' '.repeat(size) + return input + .split('\n') + .map((line) => prefix + line) + .join('\n') } function defaultSort(entries: [string, bigint | null][]) { From bb2f1705142cc0563e44113e3aef604e7c860c0e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 16 Mar 2026 22:44:29 +0100 Subject: [PATCH 2/3] Improve canonicalization for bare values exceeding default spacing scale suggestions (#19809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for canonicalization of utilities that accept bare values and exceed the default spacing scale we use for intellisense. Right now, all utilities are behind functions, so the only way to know whether something compiles is by compiling a candidate, e.g. `w-8` and passing it to the utility functions. To help us, we use the intellisense APIs that we use for suggestions. Most utilities that accept bare values, have suggestions up until `*-96`, so `w-96 h-96` would be canonicalized to `size-96`. But the moment we exceed that, the result stays as-is. ``` → w-96 h-96 = size-96 → w-1234 h-1234 = h-1234 w-1234 ``` This PR ensures that the last scenario also gets canonicalized to `size-1234` instead of staying as `h-1234 w-1234`. ``` → w-96 h-96 = size-96 → w-1234 h-1234 = size-1234 ``` ## Test plan 1. Existing tests pass 2. Added new tests for utilities with bare values [ci-all] just to see if this additional logic doesn't cause timeouts in CI for WIndows. In my testing this doesn't have a significant impact on performance at all. --- CHANGELOG.md | 1 + .../src/canonicalize-candidates.test.ts | 7 +++ .../src/canonicalize-candidates.ts | 48 ++++++++++++++++++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a54331dbe32..1f174abca67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix crash in canonicalization step when handling utilities with empty property maps ([#19727](https://github.com/tailwindlabs/tailwindcss/pull/19727)) - Skip full reload for server only modules scanned by client CSS when using `@tailwindcss/vite` ([#19745](https://github.com/tailwindlabs/tailwindcss/pull/19745)) - Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790)) +- Improve canonicalization for bare values exceeding default spacing scale suggestions (e.g. `w-1234 h-1234` → `size-1234`) ([#19809](https://github.com/tailwindlabs/tailwindcss/pull/19809)) ## [4.2.1] - 2026-02-23 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 505c473a5900..efcb351a345c 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -1054,6 +1054,13 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', // To completely different utility ['w-4 h-4', 'size-4'], + // Goes beyond the default spacing scale that's being used in intellisense + // for code completion. Since it's about bare values, we should still be + // able to combine them. + ['w-123 h-123', 'size-123'], + ['w-128 h-128', 'size-128'], // `w-128` on its own would become `w-lg` + ['mt-123 mb-123', 'my-123'], + // Do not touch if not operating on the same variants ['hover:w-4 h-4', 'hover:w-4 h-4'], diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index f26516c136b7..8670dd2c1f6c 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -312,12 +312,52 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st } } + let dynamicUtilities = new DefaultMap((candidate: string) => { + let result = new DefaultMap( + (_property: string) => new DefaultMap((_value: string) => new Set()), + ) + + let relevantProperties = new Set(computeUtilitiesPropertiesLookup.get(candidate).keys()) + if (relevantProperties.size === 0) return result + + for (let parsedCandidate of parseCandidate(designSystem, candidate)) { + if ( + parsedCandidate.kind !== 'functional' || + parsedCandidate.value?.kind !== 'named' // Necessary for bare values + ) { + continue + } + + for (let root of designSystem.utilities.keys('functional')) { + if (root === parsedCandidate.root) continue // Skip self + + let replacement = printUnprefixedCandidate(designSystem, { + ...cloneCandidate(parsedCandidate), + root, + }) + + let propertyValues = computeUtilitiesPropertiesLookup.get(replacement) + for (let [property, values] of propertyValues) { + if (!relevantProperties.has(property)) continue // Skip properties that are not relevant for the current candidate + + for (let value of values) { + result.get(property).get(value).add(replacement) + } + } + } + + return result + } + + return result + }) + // For each property, lookup other utilities that also set this property and // this exact value. If multiple properties are used, use the intersection of // each property. // // E.g.: `margin-top` → `mt-1`, `my-1`, `m-1` - let otherUtilities = candidatePropertiesValues.map((propertyValues) => { + let otherUtilities = candidatePropertiesValues.map((propertyValues, idx) => { let result: Set | null = null for (let property of propertyValues.keys()) { let otherUtilities = new Set() @@ -327,6 +367,12 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st } } + for (let value of propertyValues.get(property)) { + for (let candidate of dynamicUtilities.get(candidates[idx]).get(property).get(value)) { + otherUtilities.add(candidate) + } + } + if (result === null) result = otherUtilities else result = intersection(result, otherUtilities) From f302fce815786dedbb97baf81f666d9a28c55a24 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 17 Mar 2026 13:00:31 +0100 Subject: [PATCH 3/3] Fix canonicalization resulting in empty list (#19812) This PR fixes a bug in the canonicalization process where if a few utilities collapse into a smaller one, and the smaller one is part of the original list, then it results in an empty list. It will be more clear with an example. Let's say you have this setup: ``` w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5 ``` The first step is that this will result in: ``` w-5 h-5 size-5 ``` Then the `w-5 h-5` can turn into `size-5`. But the existing `size-5`, can also be replaced by the `size-5`. Internally, when we have a replacement, then we mark all the classes that can be replaced as "droppable", so they would be dropped from the list. But in this scenario we also marked `size-5` as droppable, resulting in an empty list. If an additional class existed: ``` w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5 flex ``` The result would be ``` flex ``` Instead of the expected: ``` size-5 flex ``` ## Test plan 1. Existing tests pass 2. Added new tests with and without an additional class --- CHANGELOG.md | 1 + .../tailwindcss/src/canonicalize-candidates.test.ts | 10 ++++++++++ packages/tailwindcss/src/canonicalize-candidates.ts | 12 ++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f174abca67b..4008c059cdb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Skip full reload for server only modules scanned by client CSS when using `@tailwindcss/vite` ([#19745](https://github.com/tailwindlabs/tailwindcss/pull/19745)) - Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790)) - Improve canonicalization for bare values exceeding default spacing scale suggestions (e.g. `w-1234 h-1234` → `size-1234`) ([#19809](https://github.com/tailwindlabs/tailwindcss/pull/19809)) +- Fix canonicalization resulting in empty list (e.g. `w-5 h-5 size-5` → `` instead of `size-5`) ([#19812](https://github.com/tailwindlabs/tailwindcss/pull/19812)) ## [4.2.1] - 2026-02-23 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index efcb351a345c..4b6ecbfd6c3c 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -1061,6 +1061,16 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['w-128 h-128', 'size-128'], // `w-128` on its own would become `w-lg` ['mt-123 mb-123', 'my-123'], + // Collapse duplicates into themselves + ['w-8 w-8', 'w-8'], + + // `w-*` and `h-*` would canonicalize to `size-5` + // `size-5` and `size-5` should then canonicalize to `size-5` + ['w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5', 'size-5'], + + // Same as above, but with an additional unrelated class + ['w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5 flex', 'size-5 flex'], + // Do not touch if not operating on the same variants ['hover:w-4 h-4', 'hover:w-4 h-4'], diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 8670dd2c1f6c..566b4a3f76b2 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -446,13 +446,17 @@ function collapseCandidates(options: InternalCanonicalizeOptions, candidates: st designSystem.storage[UTILITY_SIGNATURE_KEY].get(signatureOptions).get(replacement) if (signature !== collapsedSignature) continue // Not a safe replacement - // We can replace all items in the combo with the replacement + // Use the replacement + result.add(replacement) + + // We can replace all items in the combo with the replacement. If the + // replacement is already part of the combo, keep that one around. for (let item of combo) { - drop.add(candidates[item]) + if (candidates[item] !== replacement) { + drop.add(candidates[item]) + } } - // Use the replacement - result.add(replacement) break } }