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][]) {