diff --git a/packages/cli/src/api/catalog/getTranslationsForCatalog.test.ts b/packages/cli/src/api/catalog/getTranslationsForCatalog.test.ts index 3e4f1b462..c77fea583 100644 --- a/packages/cli/src/api/catalog/getTranslationsForCatalog.test.ts +++ b/packages/cli/src/api/catalog/getTranslationsForCatalog.test.ts @@ -99,6 +99,43 @@ describe("getTranslationsForCatalog", () => { `) }) + it("Should report raw missing catalog entries even when fallbackLocales.default resolves them", async () => { + // prettier-ignore + const catalogStub = getCatalogStub({ + ...lang("pl", [ + message("hashid1", "Lorem"), + message("hashid2", "Ipsum") + ]), + ...lang("ru", [ + message("hashid1", "Lorem"), + message("hashid2", "Ipsum", true) + ]) + }) + + const actual = await getTranslationsForCatalog(catalogStub, "ru", { + sourceLocale: "en", + fallbackLocales: { + default: "pl", + }, + missingBehavior: "catalog", + }) + + expect(actual).toMatchInlineSnapshot(` + { + messages: { + hashid1: ru: translation: Lorem, + hashid2: pl: translation: Ipsum, + }, + missing: [ + { + id: hashid2, + source: Ipsum, + }, + ], + } + `) + }) + it("Should fallback to single fallbackLocales", async () => { // prettier-ignore const catalogStub = getCatalogStub({ diff --git a/packages/cli/src/api/catalog/getTranslationsForCatalog.ts b/packages/cli/src/api/catalog/getTranslationsForCatalog.ts index b738fbd75..096a4cb8f 100644 --- a/packages/cli/src/api/catalog/getTranslationsForCatalog.ts +++ b/packages/cli/src/api/catalog/getTranslationsForCatalog.ts @@ -8,9 +8,12 @@ export type TranslationMissingEvent = { id: string } +export type MissingBehavior = "resolved" | "catalog" + export type GetTranslationsOptions = { sourceLocale: string fallbackLocales: FallbackLocales + missingBehavior?: MissingBehavior } export async function getTranslationsForCatalog( @@ -76,7 +79,7 @@ function getTranslation( ) { const { fallbackLocales, sourceLocale } = options - const getTranslation = (_locale: string) => { + const getCatalogTranslation = (_locale: string) => { const localeCatalog = catalogs[_locale] return localeCatalog?.[key]?.translation } @@ -87,8 +90,10 @@ function getTranslation( if (!fL.length) return null for (const fallbackLocale of fL) { - if (catalogs[fallbackLocale] && getTranslation(fallbackLocale)) { - return getTranslation(fallbackLocale) + const fallbackTranslation = getCatalogTranslation(fallbackLocale) + + if (catalogs[fallbackLocale] && fallbackTranslation) { + return fallbackTranslation } } } @@ -99,16 +104,24 @@ function getTranslation( // -> template message // ** last resort ** // -> id + const catalogTranslation = getCatalogTranslation(locale) + const translation = // Get translation in target locale - getTranslation(locale) || + catalogTranslation || // We search in fallbackLocales as dependent of each locale getMultipleFallbacks(locale) || (sourceLocale && sourceLocale === locale && sourceLocaleFallback(catalogs[sourceLocale], key)) - if (!translation) { + const missingBehavior = options.missingBehavior ?? "resolved" + const isMissingTranslation = + missingBehavior === "catalog" + ? locale !== sourceLocale && !catalogTranslation + : !translation + + if (isMissingTranslation) { onMissing({ id: key, source: diff --git a/packages/cli/src/api/compile/compileLocale.ts b/packages/cli/src/api/compile/compileLocale.ts index 7c74b8fe6..ca507315e 100644 --- a/packages/cli/src/api/compile/compileLocale.ts +++ b/packages/cli/src/api/compile/compileLocale.ts @@ -8,7 +8,10 @@ import { createCompiledCatalog } from "../compile.js" import normalizePath from "normalize-path" import nodepath from "path" -import { createCompilationErrorMessage } from "../messages.js" +import { + createCompilationErrorMessage, + getMissingBehaviorDescription, +} from "../messages.js" import { getTranslationsForCatalog } from "../catalog/getTranslationsForCatalog.js" import { Logger } from "../logger.js" @@ -27,6 +30,7 @@ export async function compileLocale( await getTranslationsForCatalog(catalog, locale, { fallbackLocales: config.fallbackLocales, sourceLocale: config.sourceLocale, + missingBehavior: options.missingBehavior, }) if ( @@ -42,7 +46,12 @@ export async function compileLocale( ) if (options.verbose) { - logger.error(styleText("red", "Missing translations:")) + logger.error( + styleText( + "red", + `Missing translations ${getMissingBehaviorDescription(options.missingBehavior ?? "resolved")}:`, + ), + ) missingMessages.forEach((missing) => { const source = missing.source || missing.source === missing.id @@ -53,7 +62,10 @@ export async function compileLocale( }) } else { logger.error( - styleText("red", `Missing ${missingMessages.length} translation(s)`), + styleText( + "red", + `Missing ${missingMessages.length} translation(s) ${getMissingBehaviorDescription(options.missingBehavior ?? "resolved")}`, + ), ) } logger.error("") diff --git a/packages/cli/src/api/index.ts b/packages/cli/src/api/index.ts index 703efc0c1..70847de9e 100644 --- a/packages/cli/src/api/index.ts +++ b/packages/cli/src/api/index.ts @@ -12,4 +12,5 @@ export { createMissingErrorMessage, createCompilationErrorMessage, } from "./messages.js" +export type { MissingBehavior } from "./catalog/getTranslationsForCatalog.js" export * from "./types.js" diff --git a/packages/cli/src/api/messages.test.ts b/packages/cli/src/api/messages.test.ts index 47d7afeda..b26b46a43 100644 --- a/packages/cli/src/api/messages.test.ts +++ b/packages/cli/src/api/messages.test.ts @@ -17,17 +17,16 @@ describe("createMissingErrorMessage", () => { source: "World", }, ], - "bla bla", + "resolved", ) expect(message).toMatchInlineSnapshot(` Failed to compile catalog for locale en! - Missing 2 translation(s): + Missing 2 translation(s) after applying fallbackLocales: 1: Hello World: World - `) }) }) diff --git a/packages/cli/src/api/messages.ts b/packages/cli/src/api/messages.ts index a3ef13328..aafb6ca56 100644 --- a/packages/cli/src/api/messages.ts +++ b/packages/cli/src/api/messages.ts @@ -1,15 +1,34 @@ -import { TranslationMissingEvent } from "./catalog/getTranslationsForCatalog.js" +import type { + MissingBehavior, + TranslationMissingEvent, +} from "./catalog/getTranslationsForCatalog.js" import { styleText } from "node:util" -import { MessageCompilationError } from "./compile.js" +import type { MessageCompilationError } from "./compile.js" + +export function getMissingBehaviorDescription( + missingBehavior: MissingBehavior, +) { + return missingBehavior === "catalog" + ? "before applying fallbackLocales" + : "after applying fallbackLocales" +} + +function isMissingBehavior(value: string): value is MissingBehavior { + return value === "resolved" || value === "catalog" +} export function createMissingErrorMessage( locale: string, missingMessages: TranslationMissingEvent[], - configurationMsg: string, + missingBehaviorOrConfigurationMsg: MissingBehavior | string = "resolved", ) { + const missingBehavior = isMissingBehavior(missingBehaviorOrConfigurationMsg) + ? missingBehaviorOrConfigurationMsg + : "resolved" + let message = `Failed to compile catalog for locale ${styleText("bold", locale)}! -Missing ${missingMessages.length} translation(s): +Missing ${missingMessages.length} translation(s) ${getMissingBehaviorDescription(missingBehavior)}: \n` missingMessages.forEach((missing) => { diff --git a/packages/cli/src/lingui-compile.ts b/packages/cli/src/lingui-compile.ts index f0dcef39e..d74b58381 100644 --- a/packages/cli/src/lingui-compile.ts +++ b/packages/cli/src/lingui-compile.ts @@ -1,6 +1,6 @@ import { styleText } from "node:util" import { watch } from "chokidar" -import { program } from "commander" +import { Option, program } from "commander" import { getConfig, LinguiConfigNormalized } from "@lingui/conf" import { helpRun } from "./api/help.js" @@ -13,10 +13,15 @@ import { } from "./api/resolveWorkersOptions.js" import ms from "ms" import { getPathsForCompileWatcher } from "./api/getPathsForCompileWatcher.js" +import { ProgramExit } from "./api/ProgramExit.js" +import type { MissingBehavior } from "./api/index.js" + +const failOnMissingModes: MissingBehavior[] = ["resolved", "catalog"] export type CliCompileOptions = { verbose?: boolean allowEmpty?: boolean + missingBehavior?: MissingBehavior failOnCompileError?: boolean typescript?: boolean watch?: boolean @@ -46,7 +51,7 @@ export async function command( try { await compileLocale(catalogs, locale, options, config, doMerge, console) } catch (err) { - if ((err as Error).name === "ProgramExit") { + if (err instanceof ProgramExit) { errored = true } else { throw err @@ -108,7 +113,8 @@ type CliArgs = { typescript?: boolean watch?: boolean namespace?: string - strict?: string + strict?: boolean + failOnMissing?: MissingBehavior config?: string debounce?: number workers?: number @@ -119,7 +125,16 @@ if (import.meta.main) { program .description("Compile message catalogs to compiled bundle.") .option("--config ", "Path to the config file") - .option("--strict", "Disable defaults for missing translations") + .option( + "--strict", + "Fail if translations are missing after applying fallbackLocales, or if compilation errors occur", + ) + .addOption( + new Option( + "--fail-on-missing ", + "Fail if translations are missing; modes: resolved (after fallbackLocales) or catalog (before fallbackLocales)", + ).choices(failOnMissingModes), + ) .option("--verbose", "Verbose output") .option("--typescript", "Create Typescript definition for compiled bundle") .option( @@ -146,10 +161,13 @@ if (import.meta.main) { ) console.log(` $ ${helpRun("compile")}`) console.log("") - console.log(" # Compile translations but fail when there are missing") - console.log(" # translations (don't replace missing translations with") - console.log(" # default messages or message IDs)") + console.log(" # Compile translations but fail when resolved output") + console.log(" # still has missing translations or compilation errors") console.log(` $ ${helpRun("compile --strict")}`) + console.log("") + console.log(" # Compile translations but fail when target catalogs") + console.log(" # have missing translations before fallbackLocales") + console.log(` $ ${helpRun("compile --fail-on-missing catalog")}`) }) .parse(process.argv) @@ -160,10 +178,13 @@ if (import.meta.main) { let previousRun = Promise.resolve(true) const compile = () => { + const shouldFailOnMissing = Boolean(options.strict || options.failOnMissing) + previousRun = previousRun.then(() => command(config, { verbose: options.watch || options.verbose || false, - allowEmpty: !options.strict, + allowEmpty: !shouldFailOnMissing, + missingBehavior: options.failOnMissing ?? "resolved", failOnCompileError: !!options.strict, workersOptions: resolveWorkersOptions(options), typescript: diff --git a/packages/cli/src/test/__snapshots__/compile.test.ts.snap b/packages/cli/src/test/__snapshots__/compile.test.ts.snap index 0b8cfd27f..194aae445 100644 --- a/packages/cli/src/test/__snapshots__/compile.test.ts.snap +++ b/packages/cli/src/test/__snapshots__/compile.test.ts.snap @@ -2,7 +2,7 @@ exports[`CLI Command: Compile > allowEmpty = false > Should show error and stop compilation of catalog if message doesnt have a translation (no template) 1`] = ` Error: Failed to compile catalog for locale pl! -Missing 1 translation(s) +Missing 1 translation(s) after applying fallbackLocales `; @@ -15,17 +15,17 @@ exports[`CLI Command: Compile > allowEmpty = false > Should show error and stop exports[`CLI Command: Compile > allowEmpty = false > Should show error and stop compilation of catalog if message doesnt have a translation (with template) 2`] = ` Error: Failed to compile catalog for locale en! -Missing 1 translation(s) +Missing 1 translation(s) after applying fallbackLocales Error: Failed to compile catalog for locale pl! -Missing 1 translation(s) +Missing 1 translation(s) after applying fallbackLocales `; exports[`CLI Command: Compile > allowEmpty = false > Should show missing messages verbosely when verbose = true 1`] = ` en ⇒ en.js Error: Failed to compile catalog for locale pl! -Missing translations: +Missing translations after applying fallbackLocales: mY42CM: (Hello World) 2ZeN02: (Test String) diff --git a/packages/cli/src/test/compile.test.ts b/packages/cli/src/test/compile.test.ts index df729156f..62bb070f0 100644 --- a/packages/cli/src/test/compile.test.ts +++ b/packages/cli/src/test/compile.test.ts @@ -59,7 +59,7 @@ msgstr "" }) it("Should show error and stop compilation of catalog if message doesnt have a translation (with template)", async () => { - expect.assertions(3) + expect.assertions(4) const rootDir = await createFixtures({ "messages.pot": ` msgid "Hello World" @@ -85,8 +85,9 @@ msgstr "" en: actualFiles["en.js"], }).toMatchSnapshot() - let log = getConsoleMockCalls(console.error)! - log = log.split("\n\n").sort().join("\n\n") + const rawLog = getConsoleMockCalls(console.error) + expect(rawLog).toBeDefined() + const log = rawLog?.split("\n\n").sort().join("\n\n") expect(log).toMatchSnapshot() expect(result).toBeFalsy() @@ -155,6 +156,105 @@ msgstr "" expect(result).toBeFalsy() }) }) + + it("Should pass by default when fallbackLocales can resolve missing translation", async () => { + expect.assertions(4) + + const rootDir = await createFixtures({ + "en-US.po": ` +msgid "Hello World" +msgstr "Hello World" + `, + "en-GB.po": ` +msgid "Hello World" +msgstr "" + `, + }) + + const config = makeConfig({ + locales: ["en-US", "en-GB"], + sourceLocale: "en-US", + fallbackLocales: { + default: "en-US", + }, + rootDir, + catalogs: [ + { + path: "/{locale}", + include: [""], + exclude: [], + }, + ], + }) + + await mockConsole(async (console) => { + const result = await command(config, { + allowEmpty: false, + workersOptions: { + poolSize: 0, + }, + }) + const actualFiles = readFsToListing(config.rootDir) + + expect(actualFiles["en-US.js"]).toBeTruthy() + expect(actualFiles["en-GB.js"]).toBeTruthy() + + const log = getConsoleMockCalls(console.error) + expect(log).toBeUndefined() + expect(result).toBeTruthy() + }) + }) + + it("Should fail when catalog missing behavior ignores fallbackLocales", async () => { + expect.assertions(4) + + const rootDir = await createFixtures({ + "en-US.po": ` +msgid "Hello World" +msgstr "Hello World" + `, + "en-GB.po": ` +msgid "Hello World" +msgstr "" + `, + }) + + const config = makeConfig({ + locales: ["en-US", "en-GB"], + sourceLocale: "en-US", + fallbackLocales: { + default: "en-US", + }, + rootDir, + catalogs: [ + { + path: "/{locale}", + include: [""], + exclude: [], + }, + ], + }) + + await mockConsole(async (console) => { + const result = await command(config, { + allowEmpty: false, + missingBehavior: "catalog", + workersOptions: { + poolSize: 0, + }, + }) + const actualFiles = readFsToListing(config.rootDir) + + expect(actualFiles["en-US.js"]).toBeTruthy() + expect(actualFiles["en-GB.js"]).toBeFalsy() + + const log = getConsoleMockCalls(console.error) + expect(log).toContain( + "Missing 1 translation(s) before applying fallbackLocales", + ) + expect(result).toBeFalsy() + }) + }) }) describe("failOnCompileError", () => { diff --git a/packages/loader/src/webpackLoader.ts b/packages/loader/src/webpackLoader.ts index 01dbb7b55..d9e32849c 100644 --- a/packages/loader/src/webpackLoader.ts +++ b/packages/loader/src/webpackLoader.ts @@ -8,15 +8,18 @@ import { createMissingErrorMessage, createCompilationErrorMessage, } from "@lingui/cli/api" +import type { MissingBehavior } from "@lingui/cli/api" import type { LoaderDefinitionFunction } from "webpack" +type FailOnMissingOption = boolean | MissingBehavior + export type LinguiLoaderOptions = { config?: string /** - * If true would fail compilation on missing translations + * If true would fail compilation on missing translations after fallbackLocales are applied **/ - failOnMissing?: boolean + failOnMissing?: FailOnMissingOption /** * If true would fail compilation on message compilation errors @@ -24,6 +27,23 @@ export type LinguiLoaderOptions = { failOnCompileError?: boolean } +function isFailOnMissingEnabled(option: FailOnMissingOption | undefined) { + return option === true || option === "resolved" || option === "catalog" +} + +function getFailOnMissingBehavior( + option: FailOnMissingOption | undefined, +): MissingBehavior { + return option === "catalog" ? "catalog" : "resolved" +} + +function formatFailOnMissingOption(option: FailOnMissingOption | undefined) { + if (option === true) return "true" + if (option === "resolved") return '"resolved"' + if (option === "catalog") return '"catalog"' + return "false" +} + const loader: LoaderDefinitionFunction = async function ( source, ) { @@ -60,23 +80,29 @@ Please check that \`catalogs.path\` is filled properly.\n`, const { locale, catalog } = fileCatalog const dependency = await getCatalogDependentFiles(catalog, locale) dependency.forEach((file) => this.addDependency(path.normalize(file))) + const missingBehavior = getFailOnMissingBehavior(options.failOnMissing) const { messages, missing: missingMessages } = await catalog.getTranslations( locale, { fallbackLocales: config.fallbackLocales, sourceLocale: config.sourceLocale, + missingBehavior, }, ) if ( - options.failOnMissing && + isFailOnMissingEnabled(options.failOnMissing) && locale !== config.pseudoLocale && missingMessages.length > 0 ) { - const message = createMissingErrorMessage(locale, missingMessages, "loader") + const message = createMissingErrorMessage( + locale, + missingMessages, + missingBehavior, + ) throw new Error( - `${message}\nYou see this error because \`failOnMissing=true\` in Lingui Loader configuration.`, + `${message}\nYou see this error because \`failOnMissing=${formatFailOnMissingOption(options.failOnMissing)}\` in Lingui Loader configuration.`, ) } diff --git a/packages/loader/test/fail-on-missing-fallback/entrypoint.js b/packages/loader/test/fail-on-missing-fallback/entrypoint.js new file mode 100644 index 000000000..1d732021c --- /dev/null +++ b/packages/loader/test/fail-on-missing-fallback/entrypoint.js @@ -0,0 +1,3 @@ +export async function load() { + return await import("./locale/en-GB.po") +} diff --git a/packages/loader/test/fail-on-missing-fallback/lingui.config.js b/packages/loader/test/fail-on-missing-fallback/lingui.config.js new file mode 100644 index 000000000..e0083db73 --- /dev/null +++ b/packages/loader/test/fail-on-missing-fallback/lingui.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from "@lingui/cli" + +export default defineConfig({ + locales: ["en-US", "en-GB"], + sourceLocale: "en-US", + fallbackLocales: { + default: "en-US", + }, + catalogs: [ + { + path: "/locale/{locale}", + }, + ], +}) diff --git a/packages/loader/test/fail-on-missing-fallback/locale/en-GB.po b/packages/loader/test/fail-on-missing-fallback/locale/en-GB.po new file mode 100644 index 000000000..957d056a4 --- /dev/null +++ b/packages/loader/test/fail-on-missing-fallback/locale/en-GB.po @@ -0,0 +1,5 @@ +msgid "Hello World" +msgstr "Hello World" + +msgid "My name is {name}" +msgstr "" diff --git a/packages/loader/test/fail-on-missing-fallback/locale/en-US.po b/packages/loader/test/fail-on-missing-fallback/locale/en-US.po new file mode 100644 index 000000000..b4612b13a --- /dev/null +++ b/packages/loader/test/fail-on-missing-fallback/locale/en-US.po @@ -0,0 +1,5 @@ +msgid "Hello World" +msgstr "Hello World" + +msgid "My name is {name}" +msgstr "My name is {name}" diff --git a/packages/loader/test/loader.test.ts b/packages/loader/test/loader.test.ts index 8c84ba42e..be9ad75c7 100644 --- a/packages/loader/test/loader.test.ts +++ b/packages/loader/test/loader.test.ts @@ -58,8 +58,49 @@ describe("lingui-loader", () => { }, ) - expect(built.stats.errors![0]!.message).toContain( - "Missing 1 translation(s):", + expect(built.stats.errors?.[0]?.message).toContain( + "Missing 1 translation(s) after applying fallbackLocales:", + ) + expect(built.stats.warnings).toEqual([]) + }) + + it("should not report missing error when fallbackLocales resolve translation and failOnMissing = true", async () => { + const built = await build( + path.join(__dirname, "./fail-on-missing-fallback/entrypoint.js"), + { + failOnMissing: true, + }, + ) + + expect(built.stats.errors).toEqual([]) + expect(built.stats.warnings).toEqual([]) + }) + + it('should not report missing error when fallbackLocales resolve translation and failOnMissing = "resolved"', async () => { + const built = await build( + path.join(__dirname, "./fail-on-missing-fallback/entrypoint.js"), + { + failOnMissing: "resolved", + }, + ) + + expect(built.stats.errors).toEqual([]) + expect(built.stats.warnings).toEqual([]) + }) + + it('should report missing error when fallbackLocales resolve translation and failOnMissing = "catalog"', async () => { + const built = await build( + path.join(__dirname, "./fail-on-missing-fallback/entrypoint.js"), + { + failOnMissing: "catalog", + }, + ) + + expect(built.stats.errors?.[0]?.message).toContain( + "Missing 1 translation(s) before applying fallbackLocales:", + ) + expect(built.stats.errors?.[0]?.message).toContain( + 'failOnMissing="catalog"', ) expect(built.stats.warnings).toEqual([]) }) diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 9fd82dd4f..35652d39d 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -7,11 +7,13 @@ import { createMissingErrorMessage, createCompilationErrorMessage, } from "@lingui/cli/api" +import type { MissingBehavior } from "@lingui/cli/api" import path from "path" import type { Plugin } from "vite" import { linguiTransformerBabelPreset } from "./linguiTransformerPreset" const fileRegex = /(\.po|\?lingui)$/ +type FailOnMissingOption = boolean | MissingBehavior export type LinguiPluginOpts = { cwd?: string @@ -19,9 +21,9 @@ export type LinguiPluginOpts = { skipValidation?: boolean /** - * If true would fail compilation on missing translations + * If true would fail compilation on missing translations after fallbackLocales are applied **/ - failOnMissing?: boolean + failOnMissing?: FailOnMissingOption /** * If true would fail compilation on message compilation errors @@ -29,6 +31,23 @@ export type LinguiPluginOpts = { failOnCompileError?: boolean } +function isFailOnMissingEnabled(option: FailOnMissingOption | undefined) { + return option === true || option === "resolved" || option === "catalog" +} + +function getFailOnMissingBehavior( + option: FailOnMissingOption | undefined, +): MissingBehavior { + return option === "catalog" ? "catalog" : "resolved" +} + +function formatFailOnMissingOption(option: FailOnMissingOption | undefined) { + if (option === true) return "true" + if (option === "resolved") return '"resolved"' + if (option === "catalog") return '"catalog"' + return "false" +} + export function lingui({ failOnMissing, failOnCompileError, @@ -81,25 +100,27 @@ Please check that catalogs.path is filled properly.\n`, const dependency = await getCatalogDependentFiles(catalog, locale) dependency.forEach((file) => this.addWatchFile(file)) + const missingBehavior = getFailOnMissingBehavior(failOnMissing) const { messages, missing: missingMessages } = await catalog.getTranslations(locale, { fallbackLocales: config.fallbackLocales, sourceLocale: config.sourceLocale, + missingBehavior, }) if ( - failOnMissing && + isFailOnMissingEnabled(failOnMissing) && locale !== config.pseudoLocale && missingMessages.length > 0 ) { const message = createMissingErrorMessage( locale, missingMessages, - "loader", + missingBehavior, ) throw new Error( - `${message}\nYou see this error because \`failOnMissing=true\` in Vite Plugin configuration.`, + `${message}\nYou see this error because \`failOnMissing=${formatFailOnMissingOption(failOnMissing)}\` in Vite Plugin configuration.`, ) } diff --git a/packages/vite-plugin/test/fail-on-missing-fallback/entrypoint.js b/packages/vite-plugin/test/fail-on-missing-fallback/entrypoint.js new file mode 100644 index 000000000..1d732021c --- /dev/null +++ b/packages/vite-plugin/test/fail-on-missing-fallback/entrypoint.js @@ -0,0 +1,3 @@ +export async function load() { + return await import("./locale/en-GB.po") +} diff --git a/packages/vite-plugin/test/fail-on-missing-fallback/lingui.config.js b/packages/vite-plugin/test/fail-on-missing-fallback/lingui.config.js new file mode 100644 index 000000000..e0083db73 --- /dev/null +++ b/packages/vite-plugin/test/fail-on-missing-fallback/lingui.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from "@lingui/cli" + +export default defineConfig({ + locales: ["en-US", "en-GB"], + sourceLocale: "en-US", + fallbackLocales: { + default: "en-US", + }, + catalogs: [ + { + path: "/locale/{locale}", + }, + ], +}) diff --git a/packages/vite-plugin/test/fail-on-missing-fallback/locale/en-GB.po b/packages/vite-plugin/test/fail-on-missing-fallback/locale/en-GB.po new file mode 100644 index 000000000..957d056a4 --- /dev/null +++ b/packages/vite-plugin/test/fail-on-missing-fallback/locale/en-GB.po @@ -0,0 +1,5 @@ +msgid "Hello World" +msgstr "Hello World" + +msgid "My name is {name}" +msgstr "" diff --git a/packages/vite-plugin/test/fail-on-missing-fallback/locale/en-US.po b/packages/vite-plugin/test/fail-on-missing-fallback/locale/en-US.po new file mode 100644 index 000000000..b4612b13a --- /dev/null +++ b/packages/vite-plugin/test/fail-on-missing-fallback/locale/en-US.po @@ -0,0 +1,5 @@ +msgid "Hello World" +msgstr "Hello World" + +msgid "My name is {name}" +msgstr "My name is {name}" diff --git a/packages/vite-plugin/test/vite-plugin.test.ts b/packages/vite-plugin/test/vite-plugin.test.ts index 1f673792b..10c49a13c 100644 --- a/packages/vite-plugin/test/vite-plugin.test.ts +++ b/packages/vite-plugin/test/vite-plugin.test.ts @@ -3,6 +3,10 @@ import { lingui, LinguiPluginOpts } from "../src" import { runVite as _runVite } from "./run-vite" import macrosPlugin from "vite-plugin-babel-macros" +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + describe("vite-plugin", () => { it("should return compiled catalog", async () => { const { mod } = await runVite(`po-format`) @@ -13,7 +17,7 @@ describe("vite-plugin", () => { const { mod } = await runVite(`json-format`) expect((await mod.load()).messages).toMatchSnapshot() - }) + }, 10000) it("should report error when macro used without a plugin", async () => { expect.assertions(1) @@ -24,7 +28,7 @@ describe("vite-plugin", () => { { useVitePlugin: false, useMacroPlugin: false }, ) } catch (e) { - expect((e as Error).message).toContain( + expect(getErrorMessage(e)).toContain( 'The macro you imported from "@lingui/core/macro" is being executed outside the context of compilation.', ) } @@ -41,10 +45,42 @@ describe("vite-plugin", () => { failOnMissing: true, }) } catch (e) { - expect((e as Error).message).toContain("Missing 1 translation(s):") + expect(getErrorMessage(e)).toContain( + "Missing 1 translation(s) after applying fallbackLocales:", + ) } }) + it("should not report missing error when fallbackLocales resolve translation and failOnMissing = true", async () => { + await expect( + runVite(`fail-on-missing-fallback`, { + failOnMissing: true, + }), + ).resolves.toBeTruthy() + }, 10000) + + it('should not report missing error when fallbackLocales resolve translation and failOnMissing = "resolved"', async () => { + await expect( + runVite(`fail-on-missing-fallback`, { + failOnMissing: "resolved", + }), + ).resolves.toBeTruthy() + }, 10000) + + it('should report missing error when fallbackLocales resolve translation and failOnMissing = "catalog"', async () => { + expect.assertions(2) + try { + await runVite(`fail-on-missing-fallback`, { + failOnMissing: "catalog", + }) + } catch (e) { + expect(getErrorMessage(e)).toContain( + "Missing 1 translation(s) before applying fallbackLocales:", + ) + expect(getErrorMessage(e)).toContain('failOnMissing="catalog"') + } + }, 10000) + it("should NOT report missing messages for pseudo locale when failOnMissing = true", async () => { await expect( runVite(`fail-on-missing-pseudo`, { @@ -60,7 +96,7 @@ describe("vite-plugin", () => { failOnCompileError: true, }) } catch (e) { - expect((e as Error).message).toContain( + expect(getErrorMessage(e)).toContain( "Compilation error for 2 translation(s)", ) } @@ -84,7 +120,7 @@ describe("vite-plugin", () => { ) await mod.load() } catch (e) { - expect((e as Error).message).toMatchInlineSnapshot(` + expect(getErrorMessage(e)).toMatchInlineSnapshot(` "The macro you imported from "@lingui/core/macro" is being executed outside the context of compilation. This indicates that you don't configured correctly one of the "babel-plugin-macros" / "@lingui/swc-plugin" / "babel-plugin-lingui-macro" Additionally, dynamic imports — e.g., \`await import('@lingui/core/macro')\` — are not supported." diff --git a/website/docs/ref/cli.md b/website/docs/ref/cli.md index 8e5c47243..9161e154e 100644 --- a/website/docs/ref/cli.md +++ b/website/docs/ref/cli.md @@ -166,6 +166,7 @@ Print additional information. ```shell lingui compile [--strict] + [--fail-on-missing ] [--format ] [--verbose] [--typescript] @@ -209,7 +210,18 @@ Overwrite source locale translations from source. #### `--strict` {#compile-strict} -Fail if a catalog has missing translations. +Fail if the compiled catalog still has missing translations after `fallbackLocales` are applied, or if message compilation produces errors. + +This preserves the default validation behavior used by Lingui 6. The compiled output uses the normal fallback resolution behavior regardless of strict mode, although catalogs for locales that fail strict validation may not be emitted. + +#### `--fail-on-missing ` {#compile-fail-on-missing} + +Fail compilation when missing translations are detected in the selected mode: + +- `resolved`: fail only if a translation is still missing after `fallbackLocales` are applied. +- `catalog`: fail if the target locale catalog itself has missing translations before `fallbackLocales` are applied. + +Use `--strict --fail-on-missing catalog` to fail on catalog-level missing translations and message compilation errors at the same time. #### `--format ` {#compile-format} diff --git a/website/docs/ref/loader.md b/website/docs/ref/loader.md index 32fa60511..6df35cb3d 100644 --- a/website/docs/ref/loader.md +++ b/website/docs/ref/loader.md @@ -61,6 +61,20 @@ export async function dynamicActivate(locale: string) { Remember that the file extension is mandatory. +## Options + +### `failOnMissing` + +Fail the build when missing translations are detected. + +- `true` or `"resolved"`: fail only if a translation is still missing after `fallbackLocales` are applied. +- `"catalog"`: fail if the target locale catalog itself has missing translations before `fallbackLocales` are applied. +- `false` or omitted: do not fail the build on missing translations. + +### `failOnCompileError` + +Fail the build when message compilation produces errors. + :::note Catalogs with the `.json` extension are treated differently by Webpack-compatible bundlers. They load as ES module with default export, so your import should look like this: diff --git a/website/docs/ref/vite-plugin.md b/website/docs/ref/vite-plugin.md index 594e567ec..9f0aacbc8 100644 --- a/website/docs/ref/vite-plugin.md +++ b/website/docs/ref/vite-plugin.md @@ -48,6 +48,20 @@ export async function dynamicActivate(locale: string) { Remember that the file extension is mandatory. +## Options + +### `failOnMissing` + +Fail the build when missing translations are detected. + +- `true` or `"resolved"`: fail only if a translation is still missing after `fallbackLocales` are applied. +- `"catalog"`: fail if the target locale catalog itself has missing translations before `fallbackLocales` are applied. +- `false` or omitted: do not fail the build on missing translations. + +### `failOnCompileError` + +Fail the build when message compilation produces errors. + :::tip If you are using a format that has a different extension than `*.po`, you need to specify the `?lingui` suffix: