From 308b824df6e9550ed824b58592f7677783db5301 Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Thu, 7 May 2026 17:54:17 +0200 Subject: [PATCH 1/6] feat(cli): add --silent flag to suppress non-error output Adds a --silent option to all CLI commands (extract, extract-template, extract-experimental, compile) that suppresses informational console output while still emitting errors. Useful in CI pipelines and build scripts where output noise makes it harder to spot real failures. Closes #2311 Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/lingui-compile.ts | 17 ++++-- .../cli/src/lingui-extract-experimental.ts | 46 ++++++++------ packages/cli/src/lingui-extract-template.ts | 25 +++++--- packages/cli/src/lingui-extract.ts | 55 +++++++++-------- packages/cli/src/test/compile.test.ts | 61 +++++++++++++++++++ website/docs/ref/cli.md | 16 ++++- 6 files changed, 161 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/lingui-compile.ts b/packages/cli/src/lingui-compile.ts index f0dcef39e..d6713cfa1 100644 --- a/packages/cli/src/lingui-compile.ts +++ b/packages/cli/src/lingui-compile.ts @@ -16,6 +16,7 @@ import { getPathsForCompileWatcher } from "./api/getPathsForCompileWatcher.js" export type CliCompileOptions = { verbose?: boolean + silent?: boolean allowEmpty?: boolean failOnCompileError?: boolean typescript?: boolean @@ -34,7 +35,7 @@ export async function command( // Check config.compile.merge if catalogs for current locale are to be merged into a single compiled file const doMerge = !!config.catalogsMergePath - console.log("Compiling message catalogs…") + !options.silent && console.log("Compiling message catalogs…") let errored = false @@ -61,7 +62,8 @@ export async function command( ) } - options.verbose && + !options.silent && + options.verbose && console.log(`Use worker pool of size ${options.workersOptions.poolSize}`) const pool = createCompileWorkerPool({ @@ -97,13 +99,14 @@ export async function command( } } - console.log(`Done in ${ms(Date.now() - startTime)}`) + !options.silent && console.log(`Done in ${ms(Date.now() - startTime)}`) return !errored } type CliArgs = { verbose?: boolean + silent?: boolean allowEmpty?: boolean typescript?: boolean watch?: boolean @@ -121,6 +124,7 @@ if (import.meta.main) { .option("--config ", "Path to the config file") .option("--strict", "Disable defaults for missing translations") .option("--verbose", "Verbose output") + .option("--silent", "Suppress all output except errors") .option("--typescript", "Create Typescript definition for compiled bundle") .option( "--workers ", @@ -162,7 +166,8 @@ if (import.meta.main) { const compile = () => { previousRun = previousRun.then(() => command(config, { - verbose: options.watch || options.verbose || false, + verbose: !options.silent && (options.watch || options.verbose || false), + silent: options.silent || false, allowEmpty: !options.strict, failOnCompileError: !!options.strict, workersOptions: resolveWorkersOptions(options), @@ -189,7 +194,7 @@ if (import.meta.main) { // Check if Watch Mode is enabled if (options.watch) { - console.info(styleText("bold", "Initializing Watch Mode...")) + !options.silent && console.info(styleText("bold", "Initializing Watch Mode...")) ;(async function initWatch() { const { paths } = await getPathsForCompileWatcher(config) @@ -198,7 +203,7 @@ if (import.meta.main) { }) const onReady = () => { - console.info(styleText(["green", "bold"], "Watcher is ready!")) + !options.silent && console.info(styleText(["green", "bold"], "Watcher is ready!")) watcher .on("add", () => dispatchCompile()) .on("change", () => dispatchCompile()) diff --git a/packages/cli/src/lingui-extract-experimental.ts b/packages/cli/src/lingui-extract-experimental.ts index 3f9f5c1fd..20c64fd74 100644 --- a/packages/cli/src/lingui-extract-experimental.ts +++ b/packages/cli/src/lingui-extract-experimental.ts @@ -18,6 +18,7 @@ import { createExtractExperimentalWorkerPool } from "./api/workerPools.js" type CliExtractTemplateOptions = { verbose?: boolean + silent?: boolean files?: string[] template?: boolean locales?: string[] @@ -30,7 +31,7 @@ export default async function command( linguiConfig: LinguiConfigNormalized, options: CliExtractTemplateOptions, ): Promise { - options.verbose && console.log("Extracting messages from source files…") + !options.silent && options.verbose && console.log("Extracting messages from source files…") const extractorConfig = linguiConfig.experimental?.extractor @@ -40,17 +41,18 @@ export default async function command( ) } - console.log( - styleText( - "yellow", - [ - "You have using an experimental feature", - "Experimental features are not covered by semver, and may cause unexpected or broken application behavior." + - " Use at your own risk.", - "", - ].join("\n"), - ), - ) + !options.silent && + console.log( + styleText( + "yellow", + [ + "You have using an experimental feature", + "Experimental features are not covered by semver, and may cause unexpected or broken application behavior." + + " Use at your own risk.", + "", + ].join("\n"), + ), + ) // unfortunately we can't use os.tmpdir() in this case // on windows it might create a folder on a different disk then source code is stored @@ -82,7 +84,8 @@ export default async function command( ) } - options.verbose && + !options.silent && + options.verbose && console.log(`Use worker pool of size ${options.workersOptions.poolSize}`) const pool = createExtractExperimentalWorkerPool({ @@ -157,11 +160,13 @@ export default async function command( // cleanup temp directory await fs.rm(tempDir, { recursive: true, force: true }) - stats - .sort((a, b) => a.entry.localeCompare(b.entry)) - .forEach(({ entry, content }) => { - console.log([`Catalog statistics for ${entry}:`, content, ""].join("\n")) - }) + if (!options.silent) { + stats + .sort((a, b) => a.entry.localeCompare(b.entry)) + .forEach(({ entry, content }) => { + console.log([`Catalog statistics for ${entry}:`, content, ""].join("\n")) + }) + } return commandSuccess } @@ -169,6 +174,7 @@ export default async function command( type CliArgs = { config?: string verbose?: boolean + silent?: boolean template?: boolean locale?: string overwrite?: boolean @@ -184,6 +190,7 @@ if (import.meta.main) { .option("--clean", "Remove obsolete translations") .option("--locale ", "Only extract the specified locales") .option("--verbose", "Verbose output") + .option("--silent", "Suppress all output except errors") .option( "--workers ", "Number of worker threads to use (default: CPU count - 1, capped at 8). Pass `--workers 1` to disable worker threads and run everything in a single process", @@ -197,7 +204,8 @@ if (import.meta.main) { }) const result = command(config, { - verbose: options.verbose || false, + verbose: !options.silent && (options.verbose || false), + silent: options.silent || false, template: options.template, locales: options.locale?.split(","), overwrite: options.overwrite, diff --git a/packages/cli/src/lingui-extract-template.ts b/packages/cli/src/lingui-extract-template.ts index ed8fa137a..18ea40136 100644 --- a/packages/cli/src/lingui-extract-template.ts +++ b/packages/cli/src/lingui-extract-template.ts @@ -17,6 +17,7 @@ import { type CliExtractTemplateOptions = { verbose?: boolean + silent?: boolean files?: string[] workersOptions: WorkersOptions } @@ -25,7 +26,7 @@ export default async function command( config: LinguiConfigNormalized, options: CliExtractTemplateOptions, ): Promise { - options.verbose && console.log("Extracting messages from source files…") + !options.silent && options.verbose && console.log("Extracting messages from source files…") const catalogs = await getCatalogs(config) const catalogStats: { [path: string]: number } = {} @@ -34,7 +35,8 @@ export default async function command( let workerPool: ExtractWorkerPool | undefined if (options.workersOptions.poolSize) { - options.verbose && + !options.silent && + options.verbose && console.log(`Use worker pool of size ${options.workersOptions.poolSize}`) workerPool = createExtractWorkerPool(options.workersOptions) @@ -64,12 +66,14 @@ export default async function command( await workerPool.destroy() } } - Object.entries(catalogStats).forEach(([key, value]) => { - console.log( - `Catalog statistics for ${styleText("bold", key)}: ${styleText("green", String(value))} messages`, - ) - console.log() - }) + if (!options.silent) { + Object.entries(catalogStats).forEach(([key, value]) => { + console.log( + `Catalog statistics for ${styleText("bold", key)}: ${styleText("green", String(value))} messages`, + ) + console.log() + }) + } return commandSuccess } @@ -77,6 +81,7 @@ export default async function command( type CliArgs = { config?: string verbose?: boolean + silent?: boolean workers?: number } @@ -84,6 +89,7 @@ if (import.meta.main) { program .option("--config ", "Path to the config file") .option("--verbose", "Verbose output") + .option("--silent", "Suppress all output except errors") .option( "--workers ", "Number of worker threads to use (default: CPU count - 1, capped at 8). Pass `--workers 1` to disable worker threads and run everything in a single process", @@ -97,7 +103,8 @@ if (import.meta.main) { }) const result = command(config, { - verbose: options.verbose || false, + verbose: !options.silent && (options.verbose || false), + silent: options.silent || false, workersOptions: resolveWorkersOptions(options), }).then(() => { if (!result) process.exit(1) diff --git a/packages/cli/src/lingui-extract.ts b/packages/cli/src/lingui-extract.ts index c2038bf42..a48660e94 100644 --- a/packages/cli/src/lingui-extract.ts +++ b/packages/cli/src/lingui-extract.ts @@ -25,6 +25,7 @@ import micromatch from "micromatch" export type CliExtractOptions = { verbose: boolean + silent?: boolean files?: string[] clean: boolean overwrite: boolean @@ -38,7 +39,7 @@ export default async function command( options: CliExtractOptions, ): Promise { const startTime = Date.now() - options.verbose && console.log("Extracting messages from source files…") + !options.silent && options.verbose && console.log("Extracting messages from source files…") const catalogs = await getCatalogs(config) const catalogStats: { [path: string]: AllCatalogsType } = {} @@ -49,10 +50,11 @@ export default async function command( // important to initialize ora before worker pool, otherwise it cause // MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 unpipe listeners added to [WriteStream]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit // when workers >= 10 - const spinner = ora() + const spinner = ora({ isSilent: options.silent }) if (options.workersOptions.poolSize) { - options.verbose && + !options.silent && + options.verbose && console.log(`Use worker pool of size ${options.workersOptions.poolSize}`) workerPool = createExtractWorkerPool(options.workersOptions) @@ -97,25 +99,27 @@ export default async function command( spinner.fail(doneMsg) } - Object.entries(catalogStats).forEach(([key, value]) => { - console.log(`Catalog statistics for ${key}: `) - console.log(printStats(config, value).toString()) - console.log() - }) + if (!options.silent) { + Object.entries(catalogStats).forEach(([key, value]) => { + console.log(`Catalog statistics for ${key}: `) + console.log(printStats(config, value).toString()) + console.log() + }) - if (!options.watch) { - console.log( - `(Use "${styleText( - "yellow", - helpRun("extract"), - )}" to update catalogs with new messages.)`, - ) - console.log( - `(Use "${styleText( - "yellow", - helpRun("compile"), - )}" to compile catalogs for production. Alternatively, use bundler plugins: https://lingui.dev/ref/cli#compiling-catalogs-in-ci)`, - ) + if (!options.watch) { + console.log( + `(Use "${styleText( + "yellow", + helpRun("extract"), + )}" to update catalogs with new messages.)`, + ) + console.log( + `(Use "${styleText( + "yellow", + helpRun("compile"), + )}" to compile catalogs for production. Alternatively, use bundler plugins: https://lingui.dev/ref/cli#compiling-catalogs-in-ci)`, + ) + } } // If service key is present in configuration, synchronize with cloud translation platform @@ -152,6 +156,7 @@ export default async function command( type CliArgs = { verbose: boolean + silent?: boolean config: string convertFrom: string debounce: number @@ -187,6 +192,7 @@ if (import.meta.main) { "Debounces extraction for given amount of milliseconds", ) .option("--verbose", "Verbose output") + .option("--silent", "Suppress all output except errors") .option("--watch", "Enables Watch Mode") .argument( "[files...]", @@ -220,7 +226,8 @@ if (import.meta.main) { const extract = (filePath?: string[]) => { return command(config, { - verbose: options.watch || options.verbose || false, + verbose: !options.silent && (options.watch || options.verbose || false), + silent: options.silent || false, clean: options.watch ? false : options.clean || false, overwrite: options.watch || options.overwrite || false, locale: options.locale, @@ -255,7 +262,7 @@ if (import.meta.main) { // Check if Watch Mode is enabled if (options.watch) { - console.info(styleText("bold", "Initializing Watch Mode...")) + !options.silent && console.info(styleText("bold", "Initializing Watch Mode...")) ;(async function initWatch() { const { paths, ignored } = await getPathsForExtractWatcher(config) @@ -273,7 +280,7 @@ if (import.meta.main) { }) const onReady = () => { - console.info(styleText(["green", "bold"], "Watcher is ready!")) + !options.silent && console.info(styleText(["green", "bold"], "Watcher is ready!")) watcher .on("add", (path) => dispatchExtract([path])) .on("change", (path) => dispatchExtract([path])) diff --git a/packages/cli/src/test/compile.test.ts b/packages/cli/src/test/compile.test.ts index df729156f..e2e1ff7de 100644 --- a/packages/cli/src/test/compile.test.ts +++ b/packages/cli/src/test/compile.test.ts @@ -457,6 +457,67 @@ msgstr "{plural, }" }) }) + describe("silent", () => { + it("should suppress non-error output when silent = true", async () => { + expect.assertions(3) + + const rootDir = await createFixtures({ + "en.po": ` +msgid "Hello World" +msgstr "Hello World" + `, + "pl.po": ` +msgid "Hello World" +msgstr "Cześć świat" + `, + }) + + const config = getTestConfig(rootDir) + + await mockConsole(async (console) => { + const result = await command(config, { + silent: true, + workersOptions: { poolSize: 0 }, + }) + + expect(result).toBeTruthy() + const logOutput = getConsoleMockCalls(console.log) + expect(logOutput).toBeUndefined() + const errorOutput = getConsoleMockCalls(console.error) + expect(errorOutput).toBeUndefined() + }) + }) + + it("should still emit errors on stderr when silent = true and compilation fails", async () => { + expect.assertions(2) + + const rootDir = await createFixtures({ + "en.po": ` +msgid "Hello World" +msgstr "Hello World" + `, + "pl.po": ` +msgid "Hello World" +msgstr "" + `, + }) + + const config = getTestConfig(rootDir) + + await mockConsole(async (console) => { + const result = await command(config, { + silent: true, + allowEmpty: false, + workersOptions: { poolSize: 0 }, + }) + + expect(result).toBeFalsy() + const errorOutput = getConsoleMockCalls(console.error) + expect(errorOutput).toBeTruthy() + }) + }) + }) + describe("outputPrefix", () => { it("Should use custom output prefix in compiled files", async () => { const rootDir = await createFixtures({ diff --git a/website/docs/ref/cli.md b/website/docs/ref/cli.md index 8e5c47243..808689ffa 100644 --- a/website/docs/ref/cli.md +++ b/website/docs/ref/cli.md @@ -57,6 +57,7 @@ lingui extract [files...] [--locale ] [--convert-from ] [--verbose] + [--silent] [--watch [--debounce ]] [--workers] ``` @@ -122,6 +123,10 @@ Convert message catalogs from the previous format (see the [`format`](/ref/conf# Print additional information. +#### `--silent` {#extract-silent} + +Suppress all output except errors. Useful in CI pipelines or when running `lingui extract` as part of a build script where output noise is undesirable. + #### `--watch` {#extract-watch} Enable watch mode to monitor changes in files located in the paths specified in the configuration file or in the command itself. Note that this feature is intended for development use only, as it does not remove obsolete translations. @@ -150,7 +155,7 @@ A larger worker pool also increases memory usage. Adjust this value for your pro ### `extract-template` ```shell -lingui extract-template [--verbose] +lingui extract-template [--verbose] [--silent] ``` This command extracts messages from your source files and generates a `.pot` template file. Any artifacts created by this command can be safely ignored in version control. @@ -161,6 +166,10 @@ If your message catalogs are not synchronized with the source and some messages Print additional information. +#### `--silent` {#extract-template-silent} + +Suppress all output except errors. + ### `compile` ```shell @@ -168,6 +177,7 @@ lingui compile [--strict] [--format ] [--verbose] + [--silent] [--typescript] [--namespace ] [--watch [--debounce ]] @@ -219,6 +229,10 @@ Format of message catalogs (see the [`format`](/ref/conf#format) option for more Print additional information. +#### `--silent` {#compile-silent} + +Suppress all output except errors. Useful in CI pipelines or when running `lingui compile` as part of a build script where output noise is undesirable. + #### `--namespace` {#compile-namespace} Specify the namespace for compiled message catalogs (see also [`compileNamespace`](/ref/conf#compilenamespace) for global configuration). From 598a3ccacfc91f2b2b9d3fe3c3d9adc5385ed322 Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Thu, 7 May 2026 18:08:21 +0200 Subject: [PATCH 2/6] feat(cli): replace --verbose/--silent with --log-level Introduces a proper log level hierarchy (silent|error|warning|info|verbose) across all CLI commands (extract, extract-template, extract-experimental, compile). Adds initLogger() utility in api/logger.ts that returns no-op methods for suppressed levels. --verbose is kept as a backward-compatible alias for --log-level=verbose, with a warning if it conflicts. - silent: no output at all - error: errors only - warning: errors + warnings (used for experimental feature notice) - info: normal output, default - verbose: all output including per-locale compilation paths and pool size Closes #2311 Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/api/compile/compileLocale.ts | 28 ++--- packages/cli/src/api/logger.ts | 38 ++++++- packages/cli/src/api/workerLogger.ts | 8 +- packages/cli/src/lingui-compile.ts | 58 +++++++--- .../cli/src/lingui-extract-experimental.ts | 86 ++++++++------ packages/cli/src/lingui-extract-template.ts | 63 +++++++---- packages/cli/src/lingui-extract.ts | 107 +++++++++++------- .../test/__snapshots__/compile.test.ts.snap | 7 +- packages/cli/src/test/compile.test.ts | 64 ++++++++--- website/docs/ref/cli.md | 42 ++++--- 10 files changed, 338 insertions(+), 163 deletions(-) diff --git a/packages/cli/src/api/compile/compileLocale.ts b/packages/cli/src/api/compile/compileLocale.ts index 7c74b8fe6..55a1deaad 100644 --- a/packages/cli/src/api/compile/compileLocale.ts +++ b/packages/cli/src/api/compile/compileLocale.ts @@ -40,22 +40,17 @@ export async function compileLocale( `Error: Failed to compile catalog for locale ${styleText("bold", locale)}!`, ), ) + logger.error( + styleText("red", `Missing ${missingMessages.length} translation(s)`), + ) + missingMessages.forEach((missing) => { + const source = + missing.source || missing.source === missing.id + ? `: (${missing.source})` + : "" - if (options.verbose) { - logger.error(styleText("red", "Missing translations:")) - missingMessages.forEach((missing) => { - const source = - missing.source || missing.source === missing.id - ? `: (${missing.source})` - : "" - - logger.error(`${missing.id}${source}`) - }) - } else { - logger.error( - styleText("red", `Missing ${missingMessages.length} translation(s)`), - ) - } + logger.verbose(`${missing.id}${source}`) + }) logger.error("") throw new ProgramExit() } @@ -139,7 +134,6 @@ async function compileAndWrite( compiledPath = normalizePath(nodepath.relative(config.rootDir, compiledPath)) - options.verbose && - logger.error(styleText("green", `${locale} ⇒ ${compiledPath}`)) + logger.verbose(styleText("green", `${locale} ⇒ ${compiledPath}`)) return true } diff --git a/packages/cli/src/api/logger.ts b/packages/cli/src/api/logger.ts index 6c9a57fb8..8c95c7d66 100644 --- a/packages/cli/src/api/logger.ts +++ b/packages/cli/src/api/logger.ts @@ -1,3 +1,39 @@ +export const LOG_LEVELS = [ + "silent", + "error", + "warning", + "info", + "verbose", +] as const + +export type LogLevel = (typeof LOG_LEVELS)[number] + export type Logger = { - error: (msg: string) => void + error: (...args: unknown[]) => void + warn: (...args: unknown[]) => void + info: (...args: unknown[]) => void + verbose: (...args: unknown[]) => void +} + +function isAtLeast(current: LogLevel, minimum: LogLevel): boolean { + return LOG_LEVELS.indexOf(current) >= LOG_LEVELS.indexOf(minimum) +} + +const noop = () => {} + +export function initLogger(logLevel: LogLevel): Logger { + return { + error: isAtLeast(logLevel, "error") + ? (...args) => console.error(...args) + : noop, + warn: isAtLeast(logLevel, "warning") + ? (...args) => console.warn(...args) + : noop, + info: isAtLeast(logLevel, "info") + ? (...args) => console.log(...args) + : noop, + verbose: isAtLeast(logLevel, "verbose") + ? (...args) => console.log(...args) + : noop, + } } diff --git a/packages/cli/src/api/workerLogger.ts b/packages/cli/src/api/workerLogger.ts index b2984107a..2d9f55c1d 100644 --- a/packages/cli/src/api/workerLogger.ts +++ b/packages/cli/src/api/workerLogger.ts @@ -7,10 +7,14 @@ export type SerializedLogs = { export class WorkerLogger implements Logger { private errors: string[] = [] - error(msg: string): void { - this.errors.push(msg) + error(...args: unknown[]): void { + this.errors.push(args.join(" ")) } + warn(): void {} + info(): void {} + verbose(): void {} + flush(): SerializedLogs { const errors = this.errors.join("\n") this.errors = [] diff --git a/packages/cli/src/lingui-compile.ts b/packages/cli/src/lingui-compile.ts index d6713cfa1..5d540adf5 100644 --- a/packages/cli/src/lingui-compile.ts +++ b/packages/cli/src/lingui-compile.ts @@ -13,10 +13,10 @@ import { } from "./api/resolveWorkersOptions.js" import ms from "ms" import { getPathsForCompileWatcher } from "./api/getPathsForCompileWatcher.js" +import { initLogger, LOG_LEVELS, LogLevel } from "./api/logger.js" export type CliCompileOptions = { - verbose?: boolean - silent?: boolean + logLevel: LogLevel allowEmpty?: boolean failOnCompileError?: boolean typescript?: boolean @@ -31,11 +31,12 @@ export async function command( options: CliCompileOptions, ) { const startTime = Date.now() + const logger = initLogger(options.logLevel) // Check config.compile.merge if catalogs for current locale are to be merged into a single compiled file const doMerge = !!config.catalogsMergePath - !options.silent && console.log("Compiling message catalogs…") + logger.info("Compiling message catalogs…") let errored = false @@ -45,7 +46,7 @@ export async function command( for (const locale of config.locales) { try { - await compileLocale(catalogs, locale, options, config, doMerge, console) + await compileLocale(catalogs, locale, options, config, doMerge, logger) } catch (err) { if ((err as Error).name === "ProgramExit") { errored = true @@ -62,9 +63,9 @@ export async function command( ) } - !options.silent && - options.verbose && - console.log(`Use worker pool of size ${options.workersOptions.poolSize}`) + logger.verbose( + `Use worker pool of size ${options.workersOptions.poolSize}`, + ) const pool = createCompileWorkerPool({ poolSize: options.workersOptions.poolSize, @@ -81,7 +82,7 @@ export async function command( ) if (logs.errors) { - console.error(logs.errors) + logger.error(logs.errors) } if (exitProgram) { @@ -99,14 +100,14 @@ export async function command( } } - !options.silent && console.log(`Done in ${ms(Date.now() - startTime)}`) + logger.info(`Done in ${ms(Date.now() - startTime)}`) return !errored } type CliArgs = { + logLevel?: LogLevel verbose?: boolean - silent?: boolean allowEmpty?: boolean typescript?: boolean watch?: boolean @@ -123,8 +124,14 @@ if (import.meta.main) { .description("Compile message catalogs to compiled bundle.") .option("--config ", "Path to the config file") .option("--strict", "Disable defaults for missing translations") - .option("--verbose", "Verbose output") - .option("--silent", "Suppress all output except errors") + .option( + "--log-level ", + `Set log level (${LOG_LEVELS.join("|")})`, + ) + .option( + "--verbose", + "Verbose output (alias for --log-level=verbose)", + ) .option("--typescript", "Create Typescript definition for compiled bundle") .option( "--workers ", @@ -159,6 +166,24 @@ if (import.meta.main) { const options = program.opts() + if (options.logLevel && !LOG_LEVELS.includes(options.logLevel)) { + console.error( + `Invalid --log-level "${options.logLevel}". Valid levels: ${LOG_LEVELS.join(", ")}`, + ) + process.exit(1) + } + + let logLevel: LogLevel = options.logLevel ?? "info" + + if (options.verbose) { + if (options.logLevel && options.logLevel !== "verbose") { + console.warn( + `Warning: --verbose conflicts with --log-level=${options.logLevel}. Using --log-level=verbose.`, + ) + } + logLevel = "verbose" + } + const config = getConfig({ configPath: options.config }) let previousRun = Promise.resolve(true) @@ -166,8 +191,7 @@ if (import.meta.main) { const compile = () => { previousRun = previousRun.then(() => command(config, { - verbose: !options.silent && (options.watch || options.verbose || false), - silent: options.silent || false, + logLevel, allowEmpty: !options.strict, failOnCompileError: !!options.strict, workersOptions: resolveWorkersOptions(options), @@ -192,9 +216,11 @@ if (import.meta.main) { debounceTimer = setTimeout(() => compile(), options.debounce) } + const logger = initLogger(logLevel) + // Check if Watch Mode is enabled if (options.watch) { - !options.silent && console.info(styleText("bold", "Initializing Watch Mode...")) + logger.info(styleText("bold", "Initializing Watch Mode...")) ;(async function initWatch() { const { paths } = await getPathsForCompileWatcher(config) @@ -203,7 +229,7 @@ if (import.meta.main) { }) const onReady = () => { - !options.silent && console.info(styleText(["green", "bold"], "Watcher is ready!")) + logger.info(styleText(["green", "bold"], "Watcher is ready!")) watcher .on("add", () => dispatchCompile()) .on("change", () => dispatchCompile()) diff --git a/packages/cli/src/lingui-extract-experimental.ts b/packages/cli/src/lingui-extract-experimental.ts index 20c64fd74..5a05f5019 100644 --- a/packages/cli/src/lingui-extract-experimental.ts +++ b/packages/cli/src/lingui-extract-experimental.ts @@ -15,10 +15,10 @@ import { } from "./api/resolveWorkersOptions.js" import { extractFromBundleAndWrite } from "./extract-experimental/extractFromBundleAndWrite.js" import { createExtractExperimentalWorkerPool } from "./api/workerPools.js" +import { initLogger, LOG_LEVELS, LogLevel } from "./api/logger.js" -type CliExtractTemplateOptions = { - verbose?: boolean - silent?: boolean +type CliExtractExperimentalOptions = { + logLevel: LogLevel files?: string[] template?: boolean locales?: string[] @@ -29,9 +29,11 @@ type CliExtractTemplateOptions = { export default async function command( linguiConfig: LinguiConfigNormalized, - options: CliExtractTemplateOptions, + options: CliExtractExperimentalOptions, ): Promise { - !options.silent && options.verbose && console.log("Extracting messages from source files…") + const logger = initLogger(options.logLevel) + + logger.verbose("Extracting messages from source files…") const extractorConfig = linguiConfig.experimental?.extractor @@ -41,18 +43,17 @@ export default async function command( ) } - !options.silent && - console.log( - styleText( - "yellow", - [ - "You have using an experimental feature", - "Experimental features are not covered by semver, and may cause unexpected or broken application behavior." + - " Use at your own risk.", - "", - ].join("\n"), - ), - ) + logger.warn( + styleText( + "yellow", + [ + "You have using an experimental feature", + "Experimental features are not covered by semver, and may cause unexpected or broken application behavior." + + " Use at your own risk.", + "", + ].join("\n"), + ), + ) // unfortunately we can't use os.tmpdir() in this case // on windows it might create a folder on a different disk then source code is stored @@ -84,9 +85,9 @@ export default async function command( ) } - !options.silent && - options.verbose && - console.log(`Use worker pool of size ${options.workersOptions.poolSize}`) + logger.verbose( + `Use worker pool of size ${options.workersOptions.poolSize}`, + ) const pool = createExtractExperimentalWorkerPool({ poolSize: options.workersOptions.poolSize, @@ -160,21 +161,19 @@ export default async function command( // cleanup temp directory await fs.rm(tempDir, { recursive: true, force: true }) - if (!options.silent) { - stats - .sort((a, b) => a.entry.localeCompare(b.entry)) - .forEach(({ entry, content }) => { - console.log([`Catalog statistics for ${entry}:`, content, ""].join("\n")) - }) - } + stats + .sort((a, b) => a.entry.localeCompare(b.entry)) + .forEach(({ entry, content }) => { + logger.info([`Catalog statistics for ${entry}:`, content, ""].join("\n")) + }) return commandSuccess } type CliArgs = { config?: string + logLevel?: LogLevel verbose?: boolean - silent?: boolean template?: boolean locale?: string overwrite?: boolean @@ -189,8 +188,14 @@ if (import.meta.main) { .option("--overwrite", "Overwrite translations for source locale") .option("--clean", "Remove obsolete translations") .option("--locale ", "Only extract the specified locales") - .option("--verbose", "Verbose output") - .option("--silent", "Suppress all output except errors") + .option( + "--log-level ", + `Set log level (${LOG_LEVELS.join("|")})`, + ) + .option( + "--verbose", + "Verbose output (alias for --log-level=verbose)", + ) .option( "--workers ", "Number of worker threads to use (default: CPU count - 1, capped at 8). Pass `--workers 1` to disable worker threads and run everything in a single process", @@ -199,13 +204,30 @@ if (import.meta.main) { const options = program.opts() + if (options.logLevel && !LOG_LEVELS.includes(options.logLevel)) { + console.error( + `Invalid --log-level "${options.logLevel}". Valid levels: ${LOG_LEVELS.join(", ")}`, + ) + process.exit(1) + } + + let logLevel: LogLevel = options.logLevel ?? "info" + + if (options.verbose) { + if (options.logLevel && options.logLevel !== "verbose") { + console.warn( + `Warning: --verbose conflicts with --log-level=${options.logLevel}. Using --log-level=verbose.`, + ) + } + logLevel = "verbose" + } + const config = getConfig({ configPath: options.config, }) const result = command(config, { - verbose: !options.silent && (options.verbose || false), - silent: options.silent || false, + logLevel, template: options.template, locales: options.locale?.split(","), overwrite: options.overwrite, diff --git a/packages/cli/src/lingui-extract-template.ts b/packages/cli/src/lingui-extract-template.ts index 18ea40136..573a55362 100644 --- a/packages/cli/src/lingui-extract-template.ts +++ b/packages/cli/src/lingui-extract-template.ts @@ -14,10 +14,10 @@ import { resolveWorkersOptions, WorkersOptions, } from "./api/resolveWorkersOptions.js" +import { initLogger, LOG_LEVELS, LogLevel } from "./api/logger.js" type CliExtractTemplateOptions = { - verbose?: boolean - silent?: boolean + logLevel: LogLevel files?: string[] workersOptions: WorkersOptions } @@ -26,7 +26,10 @@ export default async function command( config: LinguiConfigNormalized, options: CliExtractTemplateOptions, ): Promise { - !options.silent && options.verbose && console.log("Extracting messages from source files…") + const logger = initLogger(options.logLevel) + + logger.verbose("Extracting messages from source files…") + const catalogs = await getCatalogs(config) const catalogStats: { [path: string]: number } = {} @@ -35,9 +38,9 @@ export default async function command( let workerPool: ExtractWorkerPool | undefined if (options.workersOptions.poolSize) { - !options.silent && - options.verbose && - console.log(`Use worker pool of size ${options.workersOptions.poolSize}`) + logger.verbose( + `Use worker pool of size ${options.workersOptions.poolSize}`, + ) workerPool = createExtractWorkerPool(options.workersOptions) } @@ -66,30 +69,35 @@ export default async function command( await workerPool.destroy() } } - if (!options.silent) { - Object.entries(catalogStats).forEach(([key, value]) => { - console.log( - `Catalog statistics for ${styleText("bold", key)}: ${styleText("green", String(value))} messages`, - ) - console.log() - }) - } + + Object.entries(catalogStats).forEach(([key, value]) => { + logger.info( + `Catalog statistics for ${styleText("bold", key)}: ${styleText("green", String(value))} messages`, + ) + logger.info("") + }) return commandSuccess } type CliArgs = { config?: string + logLevel?: LogLevel verbose?: boolean - silent?: boolean workers?: number } if (import.meta.main) { program .option("--config ", "Path to the config file") - .option("--verbose", "Verbose output") - .option("--silent", "Suppress all output except errors") + .option( + "--log-level ", + `Set log level (${LOG_LEVELS.join("|")})`, + ) + .option( + "--verbose", + "Verbose output (alias for --log-level=verbose)", + ) .option( "--workers ", "Number of worker threads to use (default: CPU count - 1, capped at 8). Pass `--workers 1` to disable worker threads and run everything in a single process", @@ -98,13 +106,30 @@ if (import.meta.main) { const options = program.opts() + if (options.logLevel && !LOG_LEVELS.includes(options.logLevel)) { + console.error( + `Invalid --log-level "${options.logLevel}". Valid levels: ${LOG_LEVELS.join(", ")}`, + ) + process.exit(1) + } + + let logLevel: LogLevel = options.logLevel ?? "info" + + if (options.verbose) { + if (options.logLevel && options.logLevel !== "verbose") { + console.warn( + `Warning: --verbose conflicts with --log-level=${options.logLevel}. Using --log-level=verbose.`, + ) + } + logLevel = "verbose" + } + const config = getConfig({ configPath: options.config, }) const result = command(config, { - verbose: !options.silent && (options.verbose || false), - silent: options.silent || false, + logLevel, workersOptions: resolveWorkersOptions(options), }).then(() => { if (!result) process.exit(1) diff --git a/packages/cli/src/lingui-extract.ts b/packages/cli/src/lingui-extract.ts index a48660e94..9ddbf8419 100644 --- a/packages/cli/src/lingui-extract.ts +++ b/packages/cli/src/lingui-extract.ts @@ -22,10 +22,10 @@ import { Catalog } from "./api/catalog.js" import { getPathsForExtractWatcher } from "./api/getPathsForExtractWatcher.js" import { glob } from "node:fs/promises" import micromatch from "micromatch" +import { initLogger, LOG_LEVELS, LogLevel } from "./api/logger.js" export type CliExtractOptions = { - verbose: boolean - silent?: boolean + logLevel: LogLevel files?: string[] clean: boolean overwrite: boolean @@ -39,7 +39,9 @@ export default async function command( options: CliExtractOptions, ): Promise { const startTime = Date.now() - !options.silent && options.verbose && console.log("Extracting messages from source files…") + const logger = initLogger(options.logLevel) + + logger.verbose("Extracting messages from source files…") const catalogs = await getCatalogs(config) const catalogStats: { [path: string]: AllCatalogsType } = {} @@ -50,12 +52,12 @@ export default async function command( // important to initialize ora before worker pool, otherwise it cause // MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 unpipe listeners added to [WriteStream]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit // when workers >= 10 - const spinner = ora({ isSilent: options.silent }) + const spinner = ora({ isSilent: options.logLevel === "silent" }) if (options.workersOptions.poolSize) { - !options.silent && - options.verbose && - console.log(`Use worker pool of size ${options.workersOptions.poolSize}`) + logger.verbose( + `Use worker pool of size ${options.workersOptions.poolSize}`, + ) workerPool = createExtractWorkerPool(options.workersOptions) } @@ -99,27 +101,25 @@ export default async function command( spinner.fail(doneMsg) } - if (!options.silent) { - Object.entries(catalogStats).forEach(([key, value]) => { - console.log(`Catalog statistics for ${key}: `) - console.log(printStats(config, value).toString()) - console.log() - }) + Object.entries(catalogStats).forEach(([key, value]) => { + logger.info(`Catalog statistics for ${key}: `) + logger.info(printStats(config, value).toString()) + logger.info("") + }) - if (!options.watch) { - console.log( - `(Use "${styleText( - "yellow", - helpRun("extract"), - )}" to update catalogs with new messages.)`, - ) - console.log( - `(Use "${styleText( - "yellow", - helpRun("compile"), - )}" to compile catalogs for production. Alternatively, use bundler plugins: https://lingui.dev/ref/cli#compiling-catalogs-in-ci)`, - ) - } + if (!options.watch) { + logger.info( + `(Use "${styleText( + "yellow", + helpRun("extract"), + )}" to update catalogs with new messages.)`, + ) + logger.info( + `(Use "${styleText( + "yellow", + helpRun("compile"), + )}" to compile catalogs for production. Alternatively, use bundler plugins: https://lingui.dev/ref/cli#compiling-catalogs-in-ci)`, + ) } // If service key is present in configuration, synchronize with cloud translation platform @@ -135,7 +135,7 @@ export default async function command( } if (!services[moduleName]) { - console.error(`Can't load service module ${moduleName}`) + logger.error(`Can't load service module ${moduleName}`) return false } @@ -144,10 +144,10 @@ export default async function command( await module .default(config, options, extractionResult) - .then(console.log) - .catch(console.error) + .then((msg) => logger.info(msg)) + .catch((err) => logger.error(err)) } catch (err) { - console.error(`Can't load service module ${moduleName}`, err) + logger.error(`Can't load service module ${moduleName}`, err) } } @@ -155,8 +155,8 @@ export default async function command( } type CliArgs = { - verbose: boolean - silent?: boolean + logLevel?: LogLevel + verbose?: boolean config: string convertFrom: string debounce: number @@ -191,8 +191,14 @@ if (import.meta.main) { "--debounce ", "Debounces extraction for given amount of milliseconds", ) - .option("--verbose", "Verbose output") - .option("--silent", "Suppress all output except errors") + .option( + "--log-level ", + `Set log level (${LOG_LEVELS.join("|")})`, + ) + .option( + "--verbose", + "Verbose output (alias for --log-level=verbose)", + ) .option("--watch", "Enables Watch Mode") .argument( "[files...]", @@ -202,10 +208,30 @@ if (import.meta.main) { const options = program.opts() + if (options.logLevel && !LOG_LEVELS.includes(options.logLevel)) { + console.error( + `Invalid --log-level "${options.logLevel}". Valid levels: ${LOG_LEVELS.join(", ")}`, + ) + process.exit(1) + } + + let logLevel: LogLevel = options.logLevel ?? "info" + + if (options.verbose) { + if (options.logLevel && options.logLevel !== "verbose") { + console.warn( + `Warning: --verbose conflicts with --log-level=${options.logLevel}. Using --log-level=verbose.`, + ) + } + logLevel = "verbose" + } + const config = getConfig({ configPath: options.config, }) + const logger = initLogger(logLevel) + let hasErrors = false if (options.locale) { @@ -215,10 +241,10 @@ if (import.meta.main) { if (missingLocale) { hasErrors = true - console.error( + logger.error( `Locale ${styleText("bold", missingLocale)} does not exist.`, ) - console.error() + logger.error() } } @@ -226,8 +252,7 @@ if (import.meta.main) { const extract = (filePath?: string[]) => { return command(config, { - verbose: !options.silent && (options.watch || options.verbose || false), - silent: options.silent || false, + logLevel, clean: options.watch ? false : options.clean || false, overwrite: options.watch || options.overwrite || false, locale: options.locale, @@ -262,7 +287,7 @@ if (import.meta.main) { // Check if Watch Mode is enabled if (options.watch) { - !options.silent && console.info(styleText("bold", "Initializing Watch Mode...")) + logger.info(styleText("bold", "Initializing Watch Mode...")) ;(async function initWatch() { const { paths, ignored } = await getPathsForExtractWatcher(config) @@ -280,7 +305,7 @@ if (import.meta.main) { }) const onReady = () => { - !options.silent && console.info(styleText(["green", "bold"], "Watcher is ready!")) + logger.info(styleText(["green", "bold"], "Watcher is ready!")) watcher .on("add", (path) => dispatchExtract([path])) .on("change", (path) => dispatchExtract([path])) diff --git a/packages/cli/src/test/__snapshots__/compile.test.ts.snap b/packages/cli/src/test/__snapshots__/compile.test.ts.snap index 0b8cfd27f..dcaeb0127 100644 --- a/packages/cli/src/test/__snapshots__/compile.test.ts.snap +++ b/packages/cli/src/test/__snapshots__/compile.test.ts.snap @@ -22,12 +22,9 @@ Missing 1 translation(s) `; -exports[`CLI Command: Compile > allowEmpty = false > Should show missing messages verbosely when verbose = true 1`] = ` -en ⇒ en.js +exports[`CLI Command: Compile > allowEmpty = false > Should show missing messages verbosely when logLevel = verbose 1`] = ` Error: Failed to compile catalog for locale pl! -Missing translations: -mY42CM: (Hello World) -2ZeN02: (Test String) +Missing 2 translation(s) `; diff --git a/packages/cli/src/test/compile.test.ts b/packages/cli/src/test/compile.test.ts index e2e1ff7de..8d332d80b 100644 --- a/packages/cli/src/test/compile.test.ts +++ b/packages/cli/src/test/compile.test.ts @@ -42,6 +42,7 @@ msgstr "" await mockConsole(async (console) => { const result = await command(config, { + logLevel: "info", allowEmpty: false, workersOptions: { poolSize: 0, @@ -72,6 +73,7 @@ msgstr "" await mockConsole(async (console) => { const result = await command(config, { + logLevel: "info", allowEmpty: false, workersOptions: { poolSize: 0, @@ -111,6 +113,7 @@ msgstr "" await mockConsole(async (console) => { const result = await command(config, { + logLevel: "info", allowEmpty: false, workersOptions: { poolSize: 0, @@ -127,7 +130,7 @@ msgstr "" }) }) - it("Should show missing messages verbosely when verbose = true", async () => { + it("Should show missing messages verbosely when logLevel = verbose", async () => { expect.assertions(2) const rootDir = await createFixtures({ "pl.po": ` @@ -143,8 +146,8 @@ msgstr "" await mockConsole(async (console) => { const result = await command(config, { + logLevel: "verbose", allowEmpty: false, - verbose: true, workersOptions: { poolSize: 0, }, @@ -176,6 +179,7 @@ msgstr "Hello {hello" await mockConsole(async (console) => { const result = await command(config, { + logLevel: "info", failOnCompileError: true, allowEmpty: true, workersOptions: { @@ -216,6 +220,7 @@ msgstr "Hello User" await mockConsole(async (console) => { const result = await command(config, { + logLevel: "info", failOnCompileError: false, allowEmpty: true, workersOptions: { @@ -282,6 +287,7 @@ msgstr "[PL] Bar Hello World" await mockConsole(async (console) => { const result = await command(config, { + logLevel: "info", workersOptions: { poolSize: 0, }, @@ -339,6 +345,7 @@ msgstr "Witaj {name}" await mockConsole(async (console) => { const result = await command(config, { + logLevel: "info", workersOptions: { poolSize: 2 }, }) const actualFiles = readFsToListing(config.rootDir) @@ -380,6 +387,7 @@ msgstr "{gender, select, male {On} female {Ona} other {Oni}}" // Compile with multithread disabled await mockConsole(async () => { await command(getConfig({ cwd: rootDir }), { + logLevel: "info", workersOptions: { poolSize: 0, }, @@ -415,6 +423,7 @@ msgstr "{gender, select, male {On} female {Ona} other {Oni}}" // Compile with multithread enabled await mockConsole(async () => { await command(getConfig({ cwd: rootDir }), { + logLevel: "info", workersOptions: { poolSize: 2, }, @@ -441,6 +450,7 @@ msgstr "{plural, }" await mockConsole(async (console) => { const result = await command(getConfig({ cwd: rootDir }), { + logLevel: "info", failOnCompileError: true, workersOptions: { poolSize: 2, @@ -457,8 +467,8 @@ msgstr "{plural, }" }) }) - describe("silent", () => { - it("should suppress non-error output when silent = true", async () => { + describe("logLevel", () => { + it("should suppress all output when logLevel = silent", async () => { expect.assertions(3) const rootDir = await createFixtures({ @@ -476,19 +486,17 @@ msgstr "Cześć świat" await mockConsole(async (console) => { const result = await command(config, { - silent: true, + logLevel: "silent", workersOptions: { poolSize: 0 }, }) expect(result).toBeTruthy() - const logOutput = getConsoleMockCalls(console.log) - expect(logOutput).toBeUndefined() - const errorOutput = getConsoleMockCalls(console.error) - expect(errorOutput).toBeUndefined() + expect(getConsoleMockCalls(console.log)).toBeUndefined() + expect(getConsoleMockCalls(console.error)).toBeUndefined() }) }) - it("should still emit errors on stderr when silent = true and compilation fails", async () => { + it("should suppress errors when logLevel = silent and compilation fails", async () => { expect.assertions(2) const rootDir = await createFixtures({ @@ -506,14 +514,42 @@ msgstr "" await mockConsole(async (console) => { const result = await command(config, { - silent: true, + logLevel: "silent", allowEmpty: false, workersOptions: { poolSize: 0 }, }) expect(result).toBeFalsy() - const errorOutput = getConsoleMockCalls(console.error) - expect(errorOutput).toBeTruthy() + expect(getConsoleMockCalls(console.error)).toBeUndefined() + }) + }) + + it("should emit errors on stderr when logLevel = error and compilation fails", async () => { + expect.assertions(3) + + const rootDir = await createFixtures({ + "en.po": ` +msgid "Hello World" +msgstr "Hello World" + `, + "pl.po": ` +msgid "Hello World" +msgstr "" + `, + }) + + const config = getTestConfig(rootDir) + + await mockConsole(async (console) => { + const result = await command(config, { + logLevel: "error", + allowEmpty: false, + workersOptions: { poolSize: 0 }, + }) + + expect(result).toBeFalsy() + expect(getConsoleMockCalls(console.error)).toBeTruthy() + expect(getConsoleMockCalls(console.log)).toBeUndefined() }) }) }) @@ -535,6 +571,7 @@ msgstr "Witaj świecie" await mockConsole(async () => { const result = await command(config, { + logLevel: "info", outputPrefix: "/*biome-ignore lint: auto-generated*/", workersOptions: { poolSize: 0, @@ -571,6 +608,7 @@ msgstr "Test PL" await mockConsole(async () => { const result = await command(config, { + logLevel: "info", outputPrefix: "/*oxlint-disable*/", workersOptions: { poolSize: 0, diff --git a/website/docs/ref/cli.md b/website/docs/ref/cli.md index 808689ffa..f0ba83023 100644 --- a/website/docs/ref/cli.md +++ b/website/docs/ref/cli.md @@ -56,8 +56,8 @@ lingui extract [files...] [--format ] [--locale ] [--convert-from ] + [--log-level ] [--verbose] - [--silent] [--watch [--debounce ]] [--workers] ``` @@ -119,13 +119,21 @@ Extract data for the specified locales only. Convert message catalogs from the previous format (see the [`format`](/ref/conf#format) option for more details). -#### `--verbose` {#extract-verbose} +#### `--log-level ` {#extract-log-level} + +Control output verbosity. Accepted values: -Print additional information. +| Level | Output | +|-------|--------| +| `silent` | No output | +| `error` | Errors only | +| `warning` | Errors and warnings | +| `info` | Errors, warnings, and informational messages (default) | +| `verbose` | All output including debug details | -#### `--silent` {#extract-silent} +#### `--verbose` {#extract-verbose} -Suppress all output except errors. Useful in CI pipelines or when running `lingui extract` as part of a build script where output noise is undesirable. +Alias for `--log-level=verbose`. If passed alongside `--log-level=`, `--verbose` takes precedence with a warning. #### `--watch` {#extract-watch} @@ -146,7 +154,7 @@ By default, the tool uses a simple heuristic: - On machines with more than 2 cores → `cpu.count - 1` workers - On 2-core machines → all cores -Use the `--verbose` flag to see the actual pool size. +Use `--log-level=verbose` to see the actual pool size. Worker threads can significantly improve performance on large projects. However, on small projects they may provide little benefit or even be slightly slower due to thread startup overhead. @@ -155,20 +163,20 @@ A larger worker pool also increases memory usage. Adjust this value for your pro ### `extract-template` ```shell -lingui extract-template [--verbose] [--silent] +lingui extract-template [--log-level ] [--verbose] ``` This command extracts messages from your source files and generates a `.pot` template file. Any artifacts created by this command can be safely ignored in version control. If your message catalogs are not synchronized with the source and some messages are missing, the application will fallback to the template file. Running this command before building the application is recommended to ensure all messages are accounted for. -#### `--verbose` {#extract-template-verbose} +#### `--log-level ` {#extract-template-log-level} -Print additional information. +Control output verbosity. See [`extract --log-level`](#extract-log-level) for accepted values. -#### `--silent` {#extract-template-silent} +#### `--verbose` {#extract-template-verbose} -Suppress all output except errors. +Alias for `--log-level=verbose`. ### `compile` @@ -176,8 +184,8 @@ Suppress all output except errors. lingui compile [--strict] [--format ] + [--log-level ] [--verbose] - [--silent] [--typescript] [--namespace ] [--watch [--debounce ]] @@ -225,13 +233,13 @@ Fail if a catalog has missing translations. Format of message catalogs (see the [`format`](/ref/conf#format) option for more details). -#### `--verbose` {#compile-verbose} +#### `--log-level ` {#compile-log-level} -Print additional information. +Control output verbosity. See [`extract --log-level`](#extract-log-level) for accepted values. -#### `--silent` {#compile-silent} +#### `--verbose` {#compile-verbose} -Suppress all output except errors. Useful in CI pipelines or when running `lingui compile` as part of a build script where output noise is undesirable. +Alias for `--log-level=verbose`. If passed alongside `--log-level=`, `--verbose` takes precedence with a warning. #### `--namespace` {#compile-namespace} @@ -259,7 +267,7 @@ By default, the tool uses a simple heuristic: - On machines with more than 2 cores → `cpu.count - 1` workers - On 2-core machines → all cores -Use the `--verbose` flag to see the actual pool size. +Use `--log-level=verbose` to see the actual pool size. Worker threads can significantly improve performance on large projects. However, on small projects they may provide little benefit or even be slightly slower due to thread startup overhead. From 45e0124aa2631b47325d2c8e22d4542e68c5f31d Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Thu, 7 May 2026 18:15:02 +0200 Subject: [PATCH 3/6] fix(cli): fix type errors and test failures from logLevel refactor - Add logLevel to all test call sites in index.test.ts and translationIO.test.ts - Use console.log for logger.warn to preserve stdout behavior (consistent with existing CLI convention of styling-based severity signaling) - Fix docs formatting via Prettier Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/api/logger.ts | 2 +- .../cli/src/services/translationIO.test.ts | 2 +- packages/cli/test/index.test.ts | 24 +++++++++++++++---- website/docs/ref/cli.md | 14 +++++------ 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/api/logger.ts b/packages/cli/src/api/logger.ts index 8c95c7d66..016f7e691 100644 --- a/packages/cli/src/api/logger.ts +++ b/packages/cli/src/api/logger.ts @@ -27,7 +27,7 @@ export function initLogger(logLevel: LogLevel): Logger { ? (...args) => console.error(...args) : noop, warn: isAtLeast(logLevel, "warning") - ? (...args) => console.warn(...args) + ? (...args) => console.log(...args) : noop, info: isAtLeast(logLevel, "info") ? (...args) => console.log(...args) diff --git a/packages/cli/src/services/translationIO.test.ts b/packages/cli/src/services/translationIO.test.ts index 435be8cb7..f1dd8b0c8 100644 --- a/packages/cli/src/services/translationIO.test.ts +++ b/packages/cli/src/services/translationIO.test.ts @@ -363,7 +363,7 @@ describe("TranslationIO Integration", () => { beforeEach(async () => { options = { - verbose: false, + logLevel: "info", clean: false, overwrite: false, locale: [], diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 61a6ebcae..3e2678460 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -2,6 +2,7 @@ import extractTemplateCommand from "../src/lingui-extract-template.js" import extractCommand from "../src/lingui-extract.js" import extractExperimentalCommand from "../src/lingui-extract-experimental.js" import { command as compileCommand } from "../src/lingui-compile.js" +import type { LogLevel } from "../src/api/logger.js" import fs from "fs/promises" import { globSync } from "node:fs" import nodepath from "path" @@ -51,10 +52,15 @@ async function prepare(caseFolderName: string) { return { rootDir, actualPath, existingPath, expectedPath } } -const defaultOptions = { +const defaultOptions: { + workersOptions: { poolSize: number } + clean: boolean + logLevel: LogLevel + overwrite: boolean +} = { workersOptions: { poolSize: 0 }, clean: false, - verbose: false, + logLevel: "info", overwrite: false, } @@ -118,6 +124,7 @@ describe("E2E Extractor Test", () => { await mockConsole(async (console) => { const result = await extractTemplateCommand(getConfig({ cwd: rootDir }), { + logLevel: "info", workersOptions: { poolSize: 0 }, }) @@ -145,7 +152,7 @@ describe("E2E Extractor Test", () => { await mockConsole(async (console) => { const result = await extractTemplateCommand(getConfig({ cwd: rootDir }), { workersOptions: { poolSize: 2 }, - verbose: true, + logLevel: "verbose", }) expect(result).toBeTruthy() @@ -168,7 +175,7 @@ describe("E2E Extractor Test", () => { await mockConsole(async (console) => { const result = await extractCommand(getConfig({ cwd: rootDir }), { ...defaultOptions, - verbose: true, + logLevel: "verbose", workersOptions: { poolSize: 2 }, }) @@ -206,6 +213,7 @@ describe("E2E Extractor Test", () => { await mockConsole(async (console) => { const result = await extractExperimentalCommand(config, { + logLevel: "info", template: true, workersOptions: { poolSize: 0, @@ -213,6 +221,7 @@ describe("E2E Extractor Test", () => { }) await compileCommand(config, { + logLevel: "info", allowEmpty: true, workersOptions: { poolSize: 0, @@ -249,12 +258,14 @@ describe("E2E Extractor Test", () => { const config = getConfig({ cwd: rootDir }) const result = await extractExperimentalCommand(config, { + logLevel: "info", workersOptions: { poolSize: 0, }, }) await compileCommand(config, { + logLevel: "info", allowEmpty: true, workersOptions: { poolSize: 0, @@ -301,13 +312,14 @@ describe("E2E Extractor Test", () => { const config = getConfig({ cwd: rootDir }) const result = await extractExperimentalCommand(config, { - verbose: true, + logLevel: "verbose", workersOptions: { poolSize: 2, }, }) await compileCommand(config, { + logLevel: "info", allowEmpty: true, workersOptions: { poolSize: 0, @@ -356,6 +368,7 @@ describe("E2E Extractor Test", () => { const config = getConfig({ cwd: rootDir }) const result = await extractExperimentalCommand(config, { + logLevel: "info", workersOptions: { poolSize: 2, }, @@ -394,6 +407,7 @@ describe("E2E Extractor Test", () => { }, }), { + logLevel: "info", workersOptions: { poolSize: 0, }, diff --git a/website/docs/ref/cli.md b/website/docs/ref/cli.md index f0ba83023..5543a9072 100644 --- a/website/docs/ref/cli.md +++ b/website/docs/ref/cli.md @@ -123,13 +123,13 @@ Convert message catalogs from the previous format (see the [`format`](/ref/conf# Control output verbosity. Accepted values: -| Level | Output | -|-------|--------| -| `silent` | No output | -| `error` | Errors only | -| `warning` | Errors and warnings | -| `info` | Errors, warnings, and informational messages (default) | -| `verbose` | All output including debug details | +| Level | Output | +| --------- | ------------------------------------------------------ | +| `silent` | No output | +| `error` | Errors only | +| `warning` | Errors and warnings | +| `info` | Errors, warnings, and informational messages (default) | +| `verbose` | All output including debug details | #### `--verbose` {#extract-verbose} From 74cb680bbfaf6119c7dff8e2941fe51ce6f544be Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Mon, 11 May 2026 09:54:29 +0200 Subject: [PATCH 4/6] prettier --- packages/cli/src/lingui-compile.ts | 14 +++----------- .../cli/src/lingui-extract-experimental.ts | 14 +++----------- packages/cli/src/lingui-extract-template.ts | 14 +++----------- packages/cli/src/lingui-extract.ts | 18 ++++-------------- 4 files changed, 13 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/lingui-compile.ts b/packages/cli/src/lingui-compile.ts index 5d540adf5..6d1342fbd 100644 --- a/packages/cli/src/lingui-compile.ts +++ b/packages/cli/src/lingui-compile.ts @@ -63,9 +63,7 @@ export async function command( ) } - logger.verbose( - `Use worker pool of size ${options.workersOptions.poolSize}`, - ) + logger.verbose(`Use worker pool of size ${options.workersOptions.poolSize}`) const pool = createCompileWorkerPool({ poolSize: options.workersOptions.poolSize, @@ -124,14 +122,8 @@ if (import.meta.main) { .description("Compile message catalogs to compiled bundle.") .option("--config ", "Path to the config file") .option("--strict", "Disable defaults for missing translations") - .option( - "--log-level ", - `Set log level (${LOG_LEVELS.join("|")})`, - ) - .option( - "--verbose", - "Verbose output (alias for --log-level=verbose)", - ) + .option("--log-level ", `Set log level (${LOG_LEVELS.join("|")})`) + .option("--verbose", "Verbose output (alias for --log-level=verbose)") .option("--typescript", "Create Typescript definition for compiled bundle") .option( "--workers ", diff --git a/packages/cli/src/lingui-extract-experimental.ts b/packages/cli/src/lingui-extract-experimental.ts index 5a05f5019..3040da948 100644 --- a/packages/cli/src/lingui-extract-experimental.ts +++ b/packages/cli/src/lingui-extract-experimental.ts @@ -85,9 +85,7 @@ export default async function command( ) } - logger.verbose( - `Use worker pool of size ${options.workersOptions.poolSize}`, - ) + logger.verbose(`Use worker pool of size ${options.workersOptions.poolSize}`) const pool = createExtractExperimentalWorkerPool({ poolSize: options.workersOptions.poolSize, @@ -188,14 +186,8 @@ if (import.meta.main) { .option("--overwrite", "Overwrite translations for source locale") .option("--clean", "Remove obsolete translations") .option("--locale ", "Only extract the specified locales") - .option( - "--log-level ", - `Set log level (${LOG_LEVELS.join("|")})`, - ) - .option( - "--verbose", - "Verbose output (alias for --log-level=verbose)", - ) + .option("--log-level ", `Set log level (${LOG_LEVELS.join("|")})`) + .option("--verbose", "Verbose output (alias for --log-level=verbose)") .option( "--workers ", "Number of worker threads to use (default: CPU count - 1, capped at 8). Pass `--workers 1` to disable worker threads and run everything in a single process", diff --git a/packages/cli/src/lingui-extract-template.ts b/packages/cli/src/lingui-extract-template.ts index 573a55362..aa5285709 100644 --- a/packages/cli/src/lingui-extract-template.ts +++ b/packages/cli/src/lingui-extract-template.ts @@ -38,9 +38,7 @@ export default async function command( let workerPool: ExtractWorkerPool | undefined if (options.workersOptions.poolSize) { - logger.verbose( - `Use worker pool of size ${options.workersOptions.poolSize}`, - ) + logger.verbose(`Use worker pool of size ${options.workersOptions.poolSize}`) workerPool = createExtractWorkerPool(options.workersOptions) } @@ -90,14 +88,8 @@ type CliArgs = { if (import.meta.main) { program .option("--config ", "Path to the config file") - .option( - "--log-level ", - `Set log level (${LOG_LEVELS.join("|")})`, - ) - .option( - "--verbose", - "Verbose output (alias for --log-level=verbose)", - ) + .option("--log-level ", `Set log level (${LOG_LEVELS.join("|")})`) + .option("--verbose", "Verbose output (alias for --log-level=verbose)") .option( "--workers ", "Number of worker threads to use (default: CPU count - 1, capped at 8). Pass `--workers 1` to disable worker threads and run everything in a single process", diff --git a/packages/cli/src/lingui-extract.ts b/packages/cli/src/lingui-extract.ts index 9ddbf8419..ecbea9b54 100644 --- a/packages/cli/src/lingui-extract.ts +++ b/packages/cli/src/lingui-extract.ts @@ -55,9 +55,7 @@ export default async function command( const spinner = ora({ isSilent: options.logLevel === "silent" }) if (options.workersOptions.poolSize) { - logger.verbose( - `Use worker pool of size ${options.workersOptions.poolSize}`, - ) + logger.verbose(`Use worker pool of size ${options.workersOptions.poolSize}`) workerPool = createExtractWorkerPool(options.workersOptions) } @@ -191,14 +189,8 @@ if (import.meta.main) { "--debounce ", "Debounces extraction for given amount of milliseconds", ) - .option( - "--log-level ", - `Set log level (${LOG_LEVELS.join("|")})`, - ) - .option( - "--verbose", - "Verbose output (alias for --log-level=verbose)", - ) + .option("--log-level ", `Set log level (${LOG_LEVELS.join("|")})`) + .option("--verbose", "Verbose output (alias for --log-level=verbose)") .option("--watch", "Enables Watch Mode") .argument( "[files...]", @@ -241,9 +233,7 @@ if (import.meta.main) { if (missingLocale) { hasErrors = true - logger.error( - `Locale ${styleText("bold", missingLocale)} does not exist.`, - ) + logger.error(`Locale ${styleText("bold", missingLocale)} does not exist.`) logger.error() } } From eb43b2529968344e5d16a4d54a4ede677d34a2bb Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Mon, 11 May 2026 16:02:00 +0200 Subject: [PATCH 5/6] test(cli): add tests to improve logLevel patch coverage Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/api/workerLogger.test.ts | 32 ++++++++++ packages/cli/src/test/compile.test.ts | 74 +++++++++++++++++++++++ packages/cli/test/index.test.ts | 52 ++++++++++++++-- 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/api/workerLogger.test.ts diff --git a/packages/cli/src/api/workerLogger.test.ts b/packages/cli/src/api/workerLogger.test.ts new file mode 100644 index 000000000..6d9038b4e --- /dev/null +++ b/packages/cli/src/api/workerLogger.test.ts @@ -0,0 +1,32 @@ +import { WorkerLogger } from "./workerLogger.js" + +describe("WorkerLogger", () => { + it("error joins args with space", () => { + const logger = new WorkerLogger() + logger.error("a", "b", "c") + expect(logger.flush().errors).toBe("a b c") + }) + + it("flush clears errors after returning them", () => { + const logger = new WorkerLogger() + logger.error("first") + const first = logger.flush() + expect(first.errors).toBe("first") + expect(logger.flush().errors).toBe("") + }) + + it("multiple errors joined by newline", () => { + const logger = new WorkerLogger() + logger.error("line1") + logger.error("line2") + expect(logger.flush().errors).toBe("line1\nline2") + }) + + it("warn/info/verbose are no-ops", () => { + const logger = new WorkerLogger() + logger.warn("ignored") + logger.info("ignored") + logger.verbose("ignored") + expect(logger.flush().errors).toBe("") + }) +}) diff --git a/packages/cli/src/test/compile.test.ts b/packages/cli/src/test/compile.test.ts index 8d332d80b..3cbdda4c7 100644 --- a/packages/cli/src/test/compile.test.ts +++ b/packages/cli/src/test/compile.test.ts @@ -552,6 +552,80 @@ msgstr "" expect(getConsoleMockCalls(console.log)).toBeUndefined() }) }) + + it("should emit verbose logs including worker pool size when logLevel = verbose", async () => { + expect.assertions(3) + + function getConfigText() { + const config: LinguiConfig = { + locales: ["en", "pl"], + sourceLocale: "en", + catalogs: [ + { + path: "/{locale}", + include: [""], + exclude: [], + }, + ], + } + return `export default ${JSON.stringify(config)}` + } + + const rootDir = await createFixtures({ + "en.po": ` +msgid "Hello World" +msgstr "Hello World" + `, + "pl.po": ` +msgid "Hello World" +msgstr "Cześć świat" + `, + "lingui.config.ts": getConfigText(), + }) + + const config = getConfig({ cwd: rootDir }) + + await mockConsole(async (console) => { + const result = await command(config, { + logLevel: "verbose", + workersOptions: { poolSize: 2 }, + }) + + expect(result).toBeTruthy() + const log = getConsoleMockCalls(console.log) + expect(log).toContain("Use worker pool of size 2") + expect(getConsoleMockCalls(console.error)).toBeUndefined() + }) + }) + + it("should emit locale => path log when logLevel = verbose (single-thread)", async () => { + expect.assertions(3) + + const rootDir = await createFixtures({ + "en.po": ` +msgid "Hello World" +msgstr "Hello World" + `, + "pl.po": ` +msgid "Hello World" +msgstr "Cześć świat" + `, + }) + + const config = getTestConfig(rootDir) + + await mockConsole(async (console) => { + const result = await command(config, { + logLevel: "verbose", + workersOptions: { poolSize: 0 }, + }) + + expect(result).toBeTruthy() + const log = getConsoleMockCalls(console.log) + expect(log).toContain("⇒") + expect(getConsoleMockCalls(console.error)).toBeUndefined() + }) + }) }) describe("outputPrefix", () => { diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 3e2678460..e2750f75c 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -13,16 +13,17 @@ import { vi } from "vitest" vi.mock("ora", () => { return { - default: () => { + default: (opts: any) => { + const silent = opts?.isSilent return { start(...args: any) { - console.log(args) + if (!silent) console.log(args) }, succeed(...args: any) { - console.log(args) + if (!silent) console.log(args) }, fail(...args: any) { - console.log(args) + if (!silent) console.log(args) }, } }, @@ -445,6 +446,49 @@ describe("E2E Extractor Test", () => { }) }) + it("should suppress all output when extractCommand logLevel = silent", async () => { + const { rootDir } = await prepare("extract-po-format") + + await mockConsole(async (console) => { + const result = await extractCommand( + makeConfig({ + rootDir: rootDir, + locales: ["en", "pl"], + sourceLocale: "en", + catalogs: [ + { + path: "/actual/{locale}", + include: ["/fixtures"], + }, + ], + }), + { + ...defaultOptions, + logLevel: "silent", + }, + ) + + expect(result).toBeTruthy() + expect(getConsoleMockCalls(console.log)).toBeUndefined() + expect(getConsoleMockCalls(console.error)).toBeUndefined() + }) + }) + + it("should suppress all output when extractTemplateCommand logLevel = silent", async () => { + const { rootDir } = await prepare("extract-template-po-format") + + await mockConsole(async (console) => { + const result = await extractTemplateCommand(getConfig({ cwd: rootDir }), { + logLevel: "silent", + workersOptions: { poolSize: 0 }, + }) + + expect(result).toBeTruthy() + expect(getConsoleMockCalls(console.log)).toBeUndefined() + expect(getConsoleMockCalls(console.error)).toBeUndefined() + }) + }) + it("should extract consistently with files argument", async () => { const { rootDir, actualPath, expectedPath } = await prepare( "extract-partial-consistency", From 87e4939d3594f86f2b339f853be47634ed77d840 Mon Sep 17 00:00:00 2001 From: Mathis Pinsault Date: Mon, 11 May 2026 16:07:08 +0200 Subject: [PATCH 6/6] ci: trigger re-run