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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix crash in canonicalization step when handling utilities with empty property maps ([#19727](https://github.com/tailwindlabs/tailwindcss/pull/19727))
- Skip full reload for server only modules scanned by client CSS when using `@tailwindcss/vite` ([#19745](https://github.com/tailwindlabs/tailwindcss/pull/19745))
- Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790))
- Improve canonicalization for bare values exceeding default spacing scale suggestions (e.g. `w-1234 h-1234` → `size-1234`) ([#19809](https://github.com/tailwindlabs/tailwindcss/pull/19809))
- Fix canonicalization resulting in empty list (e.g. `w-5 h-5 size-5` → `` instead of `size-5`) ([#19812](https://github.com/tailwindlabs/tailwindcss/pull/19812))

## [4.2.1] - 2026-02-23

Expand Down
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
17 changes: 17 additions & 0 deletions packages/tailwindcss/src/canonicalize-candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,23 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
// To completely different utility
['w-4 h-4', 'size-4'],

// Goes beyond the default spacing scale that's being used in intellisense
// for code completion. Since it's about bare values, we should still be
// able to combine them.
['w-123 h-123', 'size-123'],
['w-128 h-128', 'size-128'], // `w-128` on its own would become `w-lg`
['mt-123 mb-123', 'my-123'],

// Collapse duplicates into themselves
['w-8 w-8', 'w-8'],

// `w-*` and `h-*` would canonicalize to `size-5`
// `size-5` and `size-5` should then canonicalize to `size-5`
['w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5', 'size-5'],

// Same as above, but with an additional unrelated class
['w-[calc(1rem+0.25rem)] h-[calc(1rem+0.25rem)] size-5 flex', 'size-5 flex'],

// Do not touch if not operating on the same variants
['hover:w-4 h-4', 'hover:w-4 h-4'],

Expand Down
Loading
Loading