mirror of
https://github.com/aphrody-labs/tailwindcss.git
synced 2026-10-14 13:49:01 +00:00
Write plugin logs to stderr in canonicalize (#20561)
## Summary `tailwindcss canonicalize` writes its results to stdout, but the design system is loaded in the same process, so anything a plugin logs with `console.log` while it loads (e.g. the daisyUI banner) ends up in front of the results. That breaks `--format json` and shifts every line in `--stream` mode. This points `console.log`, `console.info` and `console.debug` at `console.error` for the `canonicalize` command, so stdout only carries the results and plugin output goes to stderr. Fixes #20560 ## Test plan Added an integration test with a plugin that calls `console.log`, it fails on `main` and passes with this change. Also checked by hand with the reproduction from the issue: ``` $ tailwindcss canonicalize --css app.css "p-4 px-2 py-2" 2>/dev/null p-2 $ printf 'p-4 px-2 py-2\nw-4 h-4\n' | tailwindcss canonicalize --css app.css --stream 2>/dev/null p-2 size-4 ``` --------- Co-authored-by: Robin Malfait <robin.malfait@shopify.com>
This commit is contained in:
1 parent
9833b487a8
commit
1a38f848f0
7 files changed
+139
-16
No files matched your search
@@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Canonicalization: keep inline `theme(…)` alphas as an arbitrary percentage when a custom `--opacity-*` theme value would change the opacity (e.g. `theme(colors.red.500/50%)` → `text-red-500/[50%]` with `--opacity-50: 10%`) ([#20563](https://github.com/tailwindlabs/tailwindcss/pull/20563))
|
||||
- Ensure the `@property` fallback isn't applied in Safari versions that no longer support `margin-trim: inline` ([#20569](https://github.com/tailwindlabs/tailwindcss/pull/20569))
|
||||
- Treat viewport units like `dvmin` and `svi` and font-relative units like `cap` and `rex` as lengths in arbitrary values (e.g. `text-[10dvmin]` generates `font-size` instead of `color`) ([#20516](https://github.com/tailwindlabs/tailwindcss/pull/20516))
|
||||
- Write plugin logs to stderr in `tailwindcss canonicalize` so they don't end up in the results on stdout ([#20561](https://github.com/tailwindlabs/tailwindcss/pull/20561))
|
||||
|
||||
## [4.3.3] - 2026-07-16
|
||||
|
||||
|
||||
@@ -3184,3 +3184,39 @@ test(
|
||||
function withBOM(text: string): string {
|
||||
return '\uFEFF' + text
|
||||
}
|
||||
|
||||
test(
|
||||
'canonicalize writes plugin logs to stderr',
|
||||
{
|
||||
fs: {
|
||||
'package.json': json`
|
||||
{
|
||||
"dependencies": {
|
||||
"tailwindcss": "workspace:^",
|
||||
"@tailwindcss/cli": "workspace:^"
|
||||
}
|
||||
}
|
||||
`,
|
||||
'plugin.js': js`
|
||||
module.exports = function () {
|
||||
console.log('hello from a plugin')
|
||||
}
|
||||
`,
|
||||
'index.css': css`
|
||||
@import 'tailwindcss';
|
||||
@plugin './plugin.js';
|
||||
`,
|
||||
},
|
||||
},
|
||||
async ({ exec, expect }) => {
|
||||
let { stdout, stderr, exitCode } = await exec(
|
||||
'pnpm tailwindcss canonicalize --css index.css "p-4 px-2 py-2"',
|
||||
{},
|
||||
{ streams: true },
|
||||
)
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout.trim()).toMatchInlineSnapshot(`"p-2"`)
|
||||
expect(stderr.trim()).toMatchInlineSnapshot(`"hello from a plugin"`)
|
||||
},
|
||||
)
|
||||
+28
-3
@@ -35,6 +35,17 @@ interface ChildProcessOptions {
|
||||
interface ExecOptions {
|
||||
ignoreStdErr?: boolean
|
||||
stdin?: string
|
||||
|
||||
// Return stdout, stderr and the exit code on their own, instead of one
|
||||
// string. The promise also resolves when the command exits with a non-zero
|
||||
// code.
|
||||
streams?: boolean
|
||||
}
|
||||
|
||||
interface ExecResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
interface TestConfig {
|
||||
@@ -49,6 +60,11 @@ interface TestConfig {
|
||||
interface TestContext {
|
||||
root: string
|
||||
expect: ExpectStatic
|
||||
exec(
|
||||
command: string,
|
||||
options: ChildProcessOptions | undefined,
|
||||
execOptions: ExecOptions & { streams: true },
|
||||
): Promise<ExecResult>
|
||||
exec(command: string, options?: ChildProcessOptions, execOptions?: ExecOptions): Promise<string>
|
||||
spawn(command: string, options?: ChildProcessOptions): Promise<SpawnedProcess>
|
||||
parseSourceMap(opts: string | SourceMapOptions): SourceMap
|
||||
@@ -124,7 +140,7 @@ export function test(
|
||||
command: string,
|
||||
childProcessOptions: ChildProcessOptions = {},
|
||||
execOptions: ExecOptions = {},
|
||||
) {
|
||||
): Promise<any> {
|
||||
let cwd = childProcessOptions.cwd ?? root
|
||||
let originalCommand = command
|
||||
|
||||
@@ -165,7 +181,7 @@ export function test(
|
||||
console.log(`> cd ${relative}`)
|
||||
}
|
||||
if (debug) console.log(`> ${command}`)
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise<string | ExecResult>((resolve, reject) => {
|
||||
let child = exec(
|
||||
command,
|
||||
{
|
||||
@@ -177,7 +193,16 @@ export function test(
|
||||
},
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
if (execOptions.streams && (!error || typeof error.code === 'number')) {
|
||||
if (only || debug) {
|
||||
console.log(stdout.toString() + '\n\n' + stderr.toString())
|
||||
}
|
||||
resolve({
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
exitCode: error?.code ?? 0,
|
||||
})
|
||||
} else if (error) {
|
||||
if (command !== originalCommand) {
|
||||
error.message = error.message.replace(command, originalCommand)
|
||||
let mappedError = error as any
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import path from 'node:path'
|
||||
import { PassThrough, Readable } from 'node:stream'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { runCommandLine, streamStdin } from '.'
|
||||
import { normalizeWindowsSeparators } from '../../utils/test-helpers'
|
||||
|
||||
let css = normalizeWindowsSeparators(
|
||||
path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures/input.css'),
|
||||
)
|
||||
let cssWithPluginLogs = normalizeWindowsSeparators(
|
||||
path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures/input-with-plugin-logs.css'),
|
||||
)
|
||||
|
||||
describe('runCommandLine', { timeout: 30_000 }, () => {
|
||||
test('canonicalizes, collapses, and sorts candidate groups from positional arguments', async () => {
|
||||
@@ -165,6 +168,30 @@ describe('runCommandLine', { timeout: 30_000 }, () => {
|
||||
'{"input":"mt-2 mr-2 mb-2 ml-2","output":"m-2","changed":true}\n',
|
||||
)
|
||||
})
|
||||
|
||||
test('writes plugin logs to stderr', async () => {
|
||||
let console = globalThis.console
|
||||
using stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
using stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
let result = await runCommandLine({
|
||||
argv: ['--css', cssWithPluginLogs, 'p-4 px-2 py-2 plugin-color-red'],
|
||||
stdinIsTTY: true,
|
||||
stdoutIsTTY: false,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
exitCode: 0,
|
||||
stdout: 'p-2 plugin-color-red',
|
||||
stderr: '',
|
||||
})
|
||||
expect(stdout).not.toHaveBeenCalled()
|
||||
|
||||
let logs = stderr.mock.calls.map(([chunk]) => chunk)
|
||||
expect(logs).toContain('hello from a plugin\n')
|
||||
expect(logs).toContain('compiling plugin-color-red\n')
|
||||
expect(globalThis.console).toBe(console)
|
||||
})
|
||||
})
|
||||
|
||||
function createOutput() {
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin './plugin-with-logs.mjs';
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function ({ matchUtilities }) {
|
||||
console.log('hello from a plugin')
|
||||
|
||||
matchUtilities(
|
||||
{
|
||||
'plugin-color': (value) => {
|
||||
console.log(`compiling plugin-color-${value}`)
|
||||
return { color: value }
|
||||
},
|
||||
},
|
||||
{ values: { red: 'red' } },
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
|
||||
import { Console } from 'node:console'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
@@ -100,13 +101,15 @@ export async function runCommandLine({
|
||||
}
|
||||
|
||||
if (flags['--stream']) {
|
||||
await streamStdin({
|
||||
css: flags['--css'],
|
||||
cwd,
|
||||
format,
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
await redirectConsoleToStderr(() =>
|
||||
streamStdin({
|
||||
css: flags['--css'],
|
||||
cwd,
|
||||
format,
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
}),
|
||||
)
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
}
|
||||
|
||||
@@ -116,11 +119,13 @@ export async function runCommandLine({
|
||||
return usageError('No candidate groups provided')
|
||||
}
|
||||
|
||||
let results = await processCandidateGroups({
|
||||
css: flags['--css'],
|
||||
cwd,
|
||||
inputs,
|
||||
})
|
||||
let results = await redirectConsoleToStderr(() =>
|
||||
processCandidateGroups({
|
||||
css: flags['--css'],
|
||||
cwd,
|
||||
inputs,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -274,6 +279,20 @@ async function loadDesignSystem(cssFile: string | null, cwd: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// Plugins can log while they load (e.g. the daisyUI banner), and while their
|
||||
// utilities are compiled during canonicalization. The results are written to
|
||||
// stdout, so send the console output to stderr instead.
|
||||
async function redirectConsoleToStderr<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let console = globalThis.console
|
||||
globalThis.console = new Console({ stdout: process.stderr, stderr: process.stderr })
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
globalThis.console = console
|
||||
}
|
||||
}
|
||||
|
||||
function splitCandidateGroups(input: string) {
|
||||
return input
|
||||
.split(/\r?\n/g)
|
||||
|
||||
Reference in new issue
Block a user