Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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() }
}
136 changes: 111 additions & 25 deletions packages/@tailwindcss-cli/src/commands/canonicalize/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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': {
Expand All @@ -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
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -232,7 +292,7 @@ function parseFormat(input: string): OutputFormat {
function usageError(message: string): RunCommandLineResult {
return {
exitCode: 1,
stdout: helpMessage(),
stdout: helpMessage() ?? '',
stderr: message,
}
}
Expand All @@ -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][]) {
Expand Down