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 <malfait.robin@gmail.com>
This commit is contained in:
Aaron Tinio 2026-03-16 23:00:11 +08:00 • committed by GitHub
parent d24b1127bc
commit aaaefe8b5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 170 additions and 26 deletions

View file

@ -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() }
}

View file

@ -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<void> {
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<CandidateGroupResult[]> {
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<ReturnType<typeof __unstable__loadDesignSystem>>,
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<ReturnType<typeof __unstable__loadDesignSystem>>,
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][]) {