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..016f7e691 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.log(...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.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/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 f0dcef39e..6d1342fbd 100644 --- a/packages/cli/src/lingui-compile.ts +++ b/packages/cli/src/lingui-compile.ts @@ -13,9 +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 + logLevel: LogLevel allowEmpty?: boolean failOnCompileError?: boolean typescript?: boolean @@ -30,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 - console.log("Compiling message catalogs…") + logger.info("Compiling message catalogs…") let errored = false @@ -44,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 @@ -61,8 +63,7 @@ export async function command( ) } - 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, @@ -79,7 +80,7 @@ export async function command( ) if (logs.errors) { - console.error(logs.errors) + logger.error(logs.errors) } if (exitProgram) { @@ -97,12 +98,13 @@ export async function command( } } - console.log(`Done in ${ms(Date.now() - startTime)}`) + logger.info(`Done in ${ms(Date.now() - startTime)}`) return !errored } type CliArgs = { + logLevel?: LogLevel verbose?: boolean allowEmpty?: boolean typescript?: boolean @@ -120,7 +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("--verbose", "Verbose output") + .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 ", @@ -155,6 +158,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) @@ -162,7 +183,7 @@ if (import.meta.main) { const compile = () => { previousRun = previousRun.then(() => command(config, { - verbose: options.watch || options.verbose || false, + logLevel, allowEmpty: !options.strict, failOnCompileError: !!options.strict, workersOptions: resolveWorkersOptions(options), @@ -187,9 +208,11 @@ if (import.meta.main) { debounceTimer = setTimeout(() => compile(), options.debounce) } + const logger = initLogger(logLevel) + // Check if Watch Mode is enabled if (options.watch) { - console.info(styleText("bold", "Initializing Watch Mode...")) + logger.info(styleText("bold", "Initializing Watch Mode...")) ;(async function initWatch() { const { paths } = await getPathsForCompileWatcher(config) @@ -198,7 +221,7 @@ if (import.meta.main) { }) const onReady = () => { - 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 3f9f5c1fd..3040da948 100644 --- a/packages/cli/src/lingui-extract-experimental.ts +++ b/packages/cli/src/lingui-extract-experimental.ts @@ -15,9 +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 +type CliExtractExperimentalOptions = { + logLevel: LogLevel files?: string[] template?: boolean locales?: string[] @@ -28,9 +29,11 @@ type CliExtractTemplateOptions = { export default async function command( linguiConfig: LinguiConfigNormalized, - options: CliExtractTemplateOptions, + options: CliExtractExperimentalOptions, ): Promise { - 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 @@ -40,7 +43,7 @@ export default async function command( ) } - console.log( + logger.warn( styleText( "yellow", [ @@ -82,8 +85,7 @@ export default async function command( ) } - 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,7 +162,7 @@ export default async function command( stats .sort((a, b) => a.entry.localeCompare(b.entry)) .forEach(({ entry, content }) => { - console.log([`Catalog statistics for ${entry}:`, content, ""].join("\n")) + logger.info([`Catalog statistics for ${entry}:`, content, ""].join("\n")) }) return commandSuccess @@ -168,6 +170,7 @@ export default async function command( type CliArgs = { config?: string + logLevel?: LogLevel verbose?: boolean template?: boolean locale?: string @@ -183,7 +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("--verbose", "Verbose output") + .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", @@ -192,12 +196,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.verbose || 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 ed8fa137a..aa5285709 100644 --- a/packages/cli/src/lingui-extract-template.ts +++ b/packages/cli/src/lingui-extract-template.ts @@ -14,9 +14,10 @@ import { resolveWorkersOptions, WorkersOptions, } from "./api/resolveWorkersOptions.js" +import { initLogger, LOG_LEVELS, LogLevel } from "./api/logger.js" type CliExtractTemplateOptions = { - verbose?: boolean + logLevel: LogLevel files?: string[] workersOptions: WorkersOptions } @@ -25,7 +26,10 @@ export default async function command( config: LinguiConfigNormalized, options: CliExtractTemplateOptions, ): Promise { - 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 } = {} @@ -34,8 +38,7 @@ export default async function command( let workerPool: ExtractWorkerPool | undefined if (options.workersOptions.poolSize) { - 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) } @@ -64,11 +67,12 @@ export default async function command( await workerPool.destroy() } } + Object.entries(catalogStats).forEach(([key, value]) => { - console.log( + logger.info( `Catalog statistics for ${styleText("bold", key)}: ${styleText("green", String(value))} messages`, ) - console.log() + logger.info("") }) return commandSuccess @@ -76,6 +80,7 @@ export default async function command( type CliArgs = { config?: string + logLevel?: LogLevel verbose?: boolean workers?: number } @@ -83,7 +88,8 @@ type CliArgs = { if (import.meta.main) { program .option("--config ", "Path to the config file") - .option("--verbose", "Verbose output") + .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", @@ -92,12 +98,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.verbose || 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 c2038bf42..ecbea9b54 100644 --- a/packages/cli/src/lingui-extract.ts +++ b/packages/cli/src/lingui-extract.ts @@ -22,9 +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 + logLevel: LogLevel files?: string[] clean: boolean overwrite: boolean @@ -38,7 +39,9 @@ export default async function command( options: CliExtractOptions, ): Promise { const startTime = Date.now() - 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 } = {} @@ -49,11 +52,10 @@ 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.logLevel === "silent" }) if (options.workersOptions.poolSize) { - 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) } @@ -98,19 +100,19 @@ export default async function command( } Object.entries(catalogStats).forEach(([key, value]) => { - console.log(`Catalog statistics for ${key}: `) - console.log(printStats(config, value).toString()) - console.log() + logger.info(`Catalog statistics for ${key}: `) + logger.info(printStats(config, value).toString()) + logger.info("") }) if (!options.watch) { - console.log( + logger.info( `(Use "${styleText( "yellow", helpRun("extract"), )}" to update catalogs with new messages.)`, ) - console.log( + logger.info( `(Use "${styleText( "yellow", helpRun("compile"), @@ -131,7 +133,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 } @@ -140,10 +142,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) } } @@ -151,7 +153,8 @@ export default async function command( } type CliArgs = { - verbose: boolean + logLevel?: LogLevel + verbose?: boolean config: string convertFrom: string debounce: number @@ -186,7 +189,8 @@ if (import.meta.main) { "--debounce ", "Debounces extraction for given amount of milliseconds", ) - .option("--verbose", "Verbose output") + .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...]", @@ -196,10 +200,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) { @@ -209,10 +233,8 @@ if (import.meta.main) { if (missingLocale) { hasErrors = true - console.error( - `Locale ${styleText("bold", missingLocale)} does not exist.`, - ) - console.error() + logger.error(`Locale ${styleText("bold", missingLocale)} does not exist.`) + logger.error() } } @@ -220,7 +242,7 @@ if (import.meta.main) { const extract = (filePath?: string[]) => { return command(config, { - verbose: options.watch || options.verbose || false, + logLevel, clean: options.watch ? false : options.clean || false, overwrite: options.watch || options.overwrite || false, locale: options.locale, @@ -255,7 +277,7 @@ if (import.meta.main) { // Check if Watch Mode is enabled if (options.watch) { - console.info(styleText("bold", "Initializing Watch Mode...")) + logger.info(styleText("bold", "Initializing Watch Mode...")) ;(async function initWatch() { const { paths, ignored } = await getPathsForExtractWatcher(config) @@ -273,7 +295,7 @@ if (import.meta.main) { }) const onReady = () => { - 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/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/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 df729156f..3cbdda4c7 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,6 +467,167 @@ msgstr "{plural, }" }) }) + describe("logLevel", () => { + it("should suppress all output when logLevel = silent", 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: "silent", + workersOptions: { poolSize: 0 }, + }) + + expect(result).toBeTruthy() + expect(getConsoleMockCalls(console.log)).toBeUndefined() + expect(getConsoleMockCalls(console.error)).toBeUndefined() + }) + }) + + it("should suppress errors when logLevel = silent 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, { + logLevel: "silent", + allowEmpty: false, + workersOptions: { poolSize: 0 }, + }) + + expect(result).toBeFalsy() + 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() + }) + }) + + 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", () => { it("Should use custom output prefix in compiled files", async () => { const rootDir = await createFixtures({ @@ -474,6 +645,7 @@ msgstr "Witaj świecie" await mockConsole(async () => { const result = await command(config, { + logLevel: "info", outputPrefix: "/*biome-ignore lint: auto-generated*/", workersOptions: { poolSize: 0, @@ -510,6 +682,7 @@ msgstr "Test PL" await mockConsole(async () => { const result = await command(config, { + logLevel: "info", outputPrefix: "/*oxlint-disable*/", workersOptions: { poolSize: 0, diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 61a6ebcae..e2750f75c 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" @@ -12,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) }, } }, @@ -51,10 +53,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 +125,7 @@ describe("E2E Extractor Test", () => { await mockConsole(async (console) => { const result = await extractTemplateCommand(getConfig({ cwd: rootDir }), { + logLevel: "info", workersOptions: { poolSize: 0 }, }) @@ -145,7 +153,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 +176,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 +214,7 @@ describe("E2E Extractor Test", () => { await mockConsole(async (console) => { const result = await extractExperimentalCommand(config, { + logLevel: "info", template: true, workersOptions: { poolSize: 0, @@ -213,6 +222,7 @@ describe("E2E Extractor Test", () => { }) await compileCommand(config, { + logLevel: "info", allowEmpty: true, workersOptions: { poolSize: 0, @@ -249,12 +259,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 +313,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 +369,7 @@ describe("E2E Extractor Test", () => { const config = getConfig({ cwd: rootDir }) const result = await extractExperimentalCommand(config, { + logLevel: "info", workersOptions: { poolSize: 2, }, @@ -394,6 +408,7 @@ describe("E2E Extractor Test", () => { }, }), { + logLevel: "info", workersOptions: { poolSize: 0, }, @@ -431,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", diff --git a/website/docs/ref/cli.md b/website/docs/ref/cli.md index 8e5c47243..5543a9072 100644 --- a/website/docs/ref/cli.md +++ b/website/docs/ref/cli.md @@ -56,6 +56,7 @@ lingui extract [files...] [--format ] [--locale ] [--convert-from ] + [--log-level ] [--verbose] [--watch [--debounce ]] [--workers] @@ -118,9 +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). +#### `--log-level ` {#extract-log-level} + +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 | + #### `--verbose` {#extract-verbose} -Print additional information. +Alias for `--log-level=verbose`. If passed alongside `--log-level=`, `--verbose` takes precedence with a warning. #### `--watch` {#extract-watch} @@ -141,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. @@ -150,16 +163,20 @@ A larger worker pool also increases memory usage. Adjust this value for your pro ### `extract-template` ```shell -lingui extract-template [--verbose] +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. +#### `--log-level ` {#extract-template-log-level} + +Control output verbosity. See [`extract --log-level`](#extract-log-level) for accepted values. + #### `--verbose` {#extract-template-verbose} -Print additional information. +Alias for `--log-level=verbose`. ### `compile` @@ -167,6 +184,7 @@ Print additional information. lingui compile [--strict] [--format ] + [--log-level ] [--verbose] [--typescript] [--namespace ] @@ -215,9 +233,13 @@ Fail if a catalog has missing translations. Format of message catalogs (see the [`format`](/ref/conf#format) option for more details). +#### `--log-level ` {#compile-log-level} + +Control output verbosity. See [`extract --log-level`](#extract-log-level) for accepted values. + #### `--verbose` {#compile-verbose} -Print additional information. +Alias for `--log-level=verbose`. If passed alongside `--log-level=`, `--verbose` takes precedence with a warning. #### `--namespace` {#compile-namespace} @@ -245,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.