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:
parent
d24b1127bc
commit
aaaefe8b5d
2 changed files with 170 additions and 26 deletions
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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][]) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue