Skip to content

Commit aaaefe8

Browse files
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: #19736 --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent d24b112 commit aaaefe8

2 files changed

Lines changed: 170 additions & 26 deletions

File tree

packages/@tailwindcss-cli/src/commands/canonicalize/canonicalize.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import path from 'node:path'
2+
import { PassThrough, Readable } from 'node:stream'
23
import { fileURLToPath } from 'node:url'
34
import { describe, expect, test } from 'vitest'
4-
import { runCommandLine } from '.'
5+
import { runCommandLine, streamStdin } from '.'
56
import { normalizeWindowsSeparators } from '../../utils/test-helpers'
67

78
let css = normalizeWindowsSeparators(
@@ -114,4 +115,61 @@ describe('runCommandLine', { timeout: 30_000 }, () => {
114115
expect(result.stderr).toBe('No candidate groups provided')
115116
expect(result.stdout).toContain('Usage:')
116117
})
118+
119+
test('streams canonicalized output line by line', async () => {
120+
let input = Readable.from('py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2\n')
121+
let { stream: output, collect: collectOutput } = createOutput()
122+
123+
await streamStdin({ css, cwd: path.dirname(css), format: 'text', input, output })
124+
125+
expect(collectOutput()).toBe('p-3\nm-2\n')
126+
})
127+
128+
test('streams empty lines as empty lines', async () => {
129+
let input = Readable.from('py-3 p-1 px-3\n\nmt-2 mr-2 mb-2 ml-2\n')
130+
let { stream: output, collect: collectOutput } = createOutput()
131+
132+
await streamStdin({ css, cwd: path.dirname(css), format: 'text', input, output })
133+
134+
expect(collectOutput()).toBe('p-3\n\nm-2\n')
135+
})
136+
137+
test('streams json output when requested', async () => {
138+
let input = Readable.from('py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2\n')
139+
let { stream: output, collect: collectOutput } = createOutput()
140+
141+
await streamStdin({ css, cwd: path.dirname(css), format: 'json', input, output })
142+
143+
expect(JSON.parse(collectOutput())).toEqual([
144+
{
145+
input: 'py-3 p-1 px-3',
146+
output: 'p-3',
147+
changed: true,
148+
},
149+
{
150+
input: 'mt-2 mr-2 mb-2 ml-2',
151+
output: 'm-2',
152+
changed: true,
153+
},
154+
])
155+
})
156+
157+
test('streams jsonl output when requested', async () => {
158+
let input = Readable.from('py-3 p-1 px-3\nmt-2 mr-2 mb-2 ml-2\n')
159+
let { stream: output, collect: collectOutput } = createOutput()
160+
161+
await streamStdin({ css, cwd: path.dirname(css), format: 'jsonl', input, output })
162+
163+
expect(collectOutput()).toBe(
164+
'{"input":"py-3 p-1 px-3","output":"p-3","changed":true}\n' +
165+
'{"input":"mt-2 mr-2 mb-2 ml-2","output":"m-2","changed":true}\n',
166+
)
167+
})
117168
})
169+
170+
function createOutput() {
171+
let stream = new PassThrough()
172+
let chunks: Buffer[] = []
173+
stream.on('data', (chunk) => chunks.push(chunk))
174+
return { stream, collect: () => Buffer.concat(chunks).toString() }
175+
}

packages/@tailwindcss-cli/src/commands/canonicalize/index.ts

Lines changed: 111 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
22
import fs from 'node:fs/promises'
33
import path from 'node:path'
4+
import { createInterface } from 'node:readline'
5+
import type { Readable, Writable } from 'node:stream'
46
import { compare } from '../../../../tailwindcss/src/utils/compare'
57
import { segment } from '../../../../tailwindcss/src/utils/segment'
68
import { args, type Arg } from '../../utils/args'
@@ -37,6 +39,10 @@ function usageWithCss() {
3739
return 'tailwindcss canonicalize --css input.css [classes...]'
3840
}
3941

42+
function usageWithStream() {
43+
return 'tailwindcss canonicalize --stream [--css input.css]'
44+
}
45+
4046
export function options() {
4147
return {
4248
'--css': {
@@ -50,6 +56,11 @@ export function options() {
5056
default: 'text',
5157
values: ['text', 'json', 'jsonl'],
5258
},
59+
'--stream': {
60+
type: 'boolean',
61+
description: 'Read candidate groups from stdin line by line and write results to stdout',
62+
default: false,
63+
},
5364
} satisfies Arg
5465
}
5566

@@ -78,15 +89,27 @@ export async function runCommandLine({
7889
argv,
7990
)
8091

92+
let format = parseFormat(flags['--format'] ?? 'text')
93+
8194
if ((stdoutIsTTY && argv.length === 0) || flags['--help']) {
8295
return {
8396
exitCode: 0,
84-
stdout: helpMessage(),
97+
stdout: helpMessage() ?? '',
8598
stderr: '',
8699
}
87100
}
88101

89-
let format = parseFormat(flags['--format'])
102+
if (flags['--stream']) {
103+
await streamStdin({
104+
css: flags['--css'],
105+
cwd,
106+
format,
107+
input: process.stdin,
108+
output: process.stdout,
109+
})
110+
return { exitCode: 0, stdout: '', stderr: '' }
111+
}
112+
90113
let inputs = flags._.length > 0 ? flags._ : await readCandidateGroups({ stdin, stdinIsTTY })
91114

92115
if (inputs.length === 0) {
@@ -113,6 +136,60 @@ export async function runCommandLine({
113136
}
114137
}
115138

139+
export async function streamStdin({
140+
css: cssFile,
141+
cwd,
142+
format,
143+
input,
144+
output,
145+
}: {
146+
css: string | null
147+
cwd: string
148+
format: OutputFormat
149+
input: Readable
150+
output: Writable
151+
}): Promise<void> {
152+
let designSystem = await loadDesignSystem(cssFile, cwd)
153+
let rl = createInterface({ input })
154+
let first = true
155+
156+
if (format === 'json') {
157+
output.write('[')
158+
}
159+
160+
for await (let line of rl) {
161+
let result = createCandidateGroupResult(designSystem, line)
162+
163+
switch (format) {
164+
case 'text': {
165+
output.write(result.output + '\n')
166+
break
167+
}
168+
169+
case 'jsonl': {
170+
output.write(JSON.stringify(result) + '\n')
171+
break
172+
}
173+
174+
case 'json': {
175+
if (first) {
176+
output.write('\n')
177+
} else {
178+
output.write(',\n')
179+
}
180+
181+
output.write(indent(JSON.stringify(result, null, 2), 2))
182+
first = false
183+
break
184+
}
185+
}
186+
}
187+
188+
if (format === 'json') {
189+
output.write(first ? ']' : '\n]')
190+
}
191+
}
192+
116193
export function readCandidateGroups({
117194
stdin,
118195
stdinIsTTY,
@@ -155,24 +232,7 @@ export async function processCandidateGroups({
155232
}): Promise<CandidateGroupResult[]> {
156233
let designSystem = await loadDesignSystem(css, cwd)
157234

158-
return inputs.map((input) => {
159-
let originalCandidates = splitCandidates(input)
160-
let outputCandidates = sortCandidates(
161-
designSystem,
162-
designSystem.canonicalizeCandidates(originalCandidates, {
163-
collapse: true,
164-
logicalToPhysical: true,
165-
}),
166-
)
167-
168-
let output = outputCandidates.join(' ')
169-
170-
return {
171-
input,
172-
output,
173-
changed: output !== input,
174-
}
175-
})
235+
return inputs.map((input) => createCandidateGroupResult(designSystem, input))
176236
}
177237

178238
export function formatCandidateResults(results: CandidateGroupResult[], format: OutputFormat) {
@@ -189,7 +249,7 @@ export function formatCandidateResults(results: CandidateGroupResult[], format:
189249
function helpMessage() {
190250
return help({
191251
render: false,
192-
usage: [usage(), usageWithCss()],
252+
usage: [usage(), usageWithCss(), usageWithStream()],
193253
options: {
194254
...options(),
195255
...sharedOptions,
@@ -232,7 +292,7 @@ function parseFormat(input: string): OutputFormat {
232292
function usageError(message: string): RunCommandLineResult {
233293
return {
234294
exitCode: 1,
235-
stdout: helpMessage(),
295+
stdout: helpMessage() ?? '',
236296
stderr: message,
237297
}
238298
}
@@ -246,11 +306,37 @@ function splitCandidates(input: string) {
246306
.filter((candidate) => candidate.length > 0)
247307
}
248308

249-
function sortCandidates(
309+
function canonicalize(
250310
designSystem: Awaited<ReturnType<typeof __unstable__loadDesignSystem>>,
251-
candidates: string[],
311+
input: string,
252312
) {
253-
return defaultSort(designSystem.getClassOrder(candidates))
313+
let candidates = splitCandidates(input)
314+
candidates = designSystem.canonicalizeCandidates(candidates, {
315+
collapse: true,
316+
logicalToPhysical: true,
317+
})
318+
return defaultSort(designSystem.getClassOrder(candidates)).join(' ')
319+
}
320+
321+
function createCandidateGroupResult(
322+
designSystem: Awaited<ReturnType<typeof __unstable__loadDesignSystem>>,
323+
input: string,
324+
): CandidateGroupResult {
325+
let output = canonicalize(designSystem, input)
326+
327+
return {
328+
input,
329+
output,
330+
changed: output !== input,
331+
}
332+
}
333+
334+
function indent(input: string, size: number) {
335+
let prefix = ' '.repeat(size)
336+
return input
337+
.split('\n')
338+
.map((line) => prefix + line)
339+
.join('\n')
254340
}
255341

256342
function defaultSort(entries: [string, bigint | null][]) {

0 commit comments

Comments
 (0)