diff --git a/.changeset/native-translation-checks.md b/.changeset/native-translation-checks.md new file mode 100644 index 00000000..84709472 --- /dev/null +++ b/.changeset/native-translation-checks.md @@ -0,0 +1,5 @@ +--- +"@inlang/paraglide-js": minor +--- + +Add an opt-in `checkTranslations` compiler option that runs the inlang SDK's translation checks before writing generated files. It rejects missing translations and invalid translation forms while preserving the existing locale fallback behavior by default. diff --git a/docs-api/compiler-options.md b/docs-api/compiler-options.md index 557d4822..04f1265f 100644 --- a/docs-api/compiler-options.md +++ b/docs-api/compiler-options.md @@ -2,7 +2,7 @@ > **CompilerOptions** = `object` -Defined in: [compiler-options.ts:51](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:52](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) ### Properties @@ -10,7 +10,7 @@ Defined in: [compiler-options.ts:51](https://github.com/opral/paraglide-js/tree/ > `optional` **additionalFiles**: `Record`\<`string`, `string`\> -Defined in: [compiler-options.ts:279](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:290](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The `additionalFiles` option is an array of paths to additional files that should be copied to the output directory. @@ -36,11 +36,30 @@ The output will look like this: - runtime.js ``` +#### checkTranslations? + +> `optional` **checkTranslations**: `boolean` + +Defined in: [compiler-options.ts:62](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) + +Validate translations with the Inlang SDK before compiling messages. +Reports missing or empty translations and forms, variables, markup, +selectors, and plural/select forms. Diagnostics fail compilation before +generated files are written. Unused messages require `inlang check`. + +Disabled by default to preserve intentional locale fallback. + +##### Default + +```ts +false +``` + #### cleanOutdir? > `optional` **cleanOutdir**: `boolean` -Defined in: [compiler-options.ts:444](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:455](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Whether to clean the output directory before writing the new files. @@ -54,7 +73,7 @@ true > `optional` **cookieDomain**: `string` -Defined in: [compiler-options.ts:212](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:223](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The host to which the cookie will be sent. If undefined or empty, the domain attribute is omitted from the cookie, scoping it to the exact current domain only (no subdomains). @@ -83,7 +102,7 @@ cookieDomain: "example.com" // Cookie: "PARAGLIDE_LOCALE=en; path=/; max-age=345 > `optional` **cookieMaxAge**: `number` -Defined in: [compiler-options.ts:192](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:203](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The max-age in seconds of the cookie until it expires. @@ -97,7 +116,7 @@ The max-age in seconds of the cookie until it expires. > `optional` **cookieName**: `string` -Defined in: [compiler-options.ts:186](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:197](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The name of the cookie to use for the cookie strategy. @@ -111,7 +130,7 @@ The name of the cookie to use for the cookie strategy. > `optional` **cookieSameSite**: `"lax"` \| `"strict"` \| `"none"` -Defined in: [compiler-options.ts:231](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:242](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The `SameSite` attribute of the cookie. @@ -139,7 +158,7 @@ undefined (no samesite attribute) > `optional` **cookieSecure**: `boolean` \| `"auto"` -Defined in: [compiler-options.ts:253](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:264](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Whether the cookie gets the `Secure` attribute. @@ -170,7 +189,7 @@ false > `optional` **disableAsyncLocalStorage**: `boolean` -Defined in: [compiler-options.ts:373](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:384](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Replaces AsyncLocalStorage with a synchronous implementation. @@ -187,7 +206,7 @@ one request could leak into another concurrent request. > `optional` **emitGitIgnore**: `boolean` -Defined in: [compiler-options.ts:387](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:398](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) If `emitGitIgnore` is set to `true` a `.gitignore` file will be emitted in the output directory. Defaults to `true`. @@ -209,7 +228,7 @@ true > `optional` **emitPrettierIgnore**: `boolean` -Defined in: [compiler-options.ts:293](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:304](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) If `emitPrettierIgnore` is set to `true` a `.prettierignore` file will be emitted in the output directory. Defaults to `true`. @@ -231,7 +250,7 @@ true > `optional` **emitReadme**: `boolean` -Defined in: [compiler-options.ts:310](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:321](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) If `emitReadme` is set to `true` a `README.md` file will be emitted in the output directory. Defaults to `true`. @@ -256,7 +275,7 @@ true > `optional` **emitTsDeclarations**: `boolean` -Defined in: [compiler-options.ts:342](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:353](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Emit `.d.ts` files for the generated output using the TypeScript compiler. @@ -297,7 +316,7 @@ false > `optional` **experimentalMiddlewareLocaleSplitting**: `boolean` -Defined in: [compiler-options.ts:127](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:138](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Whether or not to use experimental middleware locale splitting. @@ -321,7 +340,7 @@ false > `optional` **experimentalStaticLocale**: `string` -Defined in: [compiler-options.ts:180](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:191](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Compile-time locale constant used for per-locale tree-shaking. @@ -365,7 +384,7 @@ https://github.com/opral/paraglide-js/issues/88#issuecomment-3634754638 > `optional` **fs**: `any` -Defined in: [compiler-options.ts:451](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:462](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The file system to use. Defaults to `await import('node:fs')`. @@ -375,7 +394,7 @@ Useful for testing the paraglide compiler by mocking the fs. > `optional` **includeEslintDisableComment**: `boolean` -Defined in: [compiler-options.ts:360](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:371](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Whether to include an eslint-disable comment at the top of each .js file. @@ -389,7 +408,7 @@ true > `optional` **isServer**: `string` -Defined in: [compiler-options.ts:146](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:157](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Tree-shaking flag if the code is running on the server. @@ -413,7 +432,7 @@ typeof window === "undefined" > `optional` **localStorageKey**: `string` -Defined in: [compiler-options.ts:133](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:144](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The name of the localStorage key to use for the localStorage strategy. @@ -427,7 +446,7 @@ The name of the localStorage key to use for the localStorage strategy. > **outdir**: `string` -Defined in: [compiler-options.ts:75](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:86](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The path to the output directory. @@ -444,7 +463,7 @@ await compile({ > `optional` **outputStructure**: `"locale-modules"` \| `"message-modules"` -Defined in: [compiler-options.ts:438](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:449](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The `outputStructure` defines how modules are structured in the output. @@ -503,7 +522,7 @@ The benefit are substantially fewer files which is needed in large projects. > **project**: `string` -Defined in: [compiler-options.ts:63](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:74](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The path to the inlang project. @@ -520,7 +539,7 @@ await compile({ > `optional` **routeStrategies**: [`RouteStrategy`](#routestrategy)[] -Defined in: [compiler-options.ts:111](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:122](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Route-level strategy overrides. @@ -543,7 +562,7 @@ routeStrategies: [ > `optional` **strategy**: [`Runtime`](runtime/type/README.md#runtime)\[`"strategy"`\] -Defined in: [compiler-options.ts:93](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:104](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The strategy to use for getting the locale. @@ -569,7 +588,7 @@ Custom strategies with the pattern `custom-[A-Za-z0-9_-]+` are supported. > `optional` **trailingSlash**: `"always"` \| `"never"` -Defined in: [compiler-options.ts:354](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:365](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Controls trailing slash canonicalization for localized URLs. @@ -581,7 +600,7 @@ behavior. > `optional` **urlPatterns**: [`Runtime`](runtime/type/README.md#runtime)\[`"urlPatterns"`\] -Defined in: [compiler-options.ts:346](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:357](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) https://paraglidejs.com/strategy#url @@ -645,6 +664,10 @@ Defined in: [compiler-options.ts:33](https://github.com/opral/paraglide-js/tree/ ### Type Declaration +#### checkTranslations + +> `readonly` **checkTranslations**: `false` = `false` + #### cleanOutdir > `readonly` **cleanOutdir**: `true` = `true` diff --git a/docs/checking-translations.md b/docs/checking-translations.md index e795e4db..4aa1593b 100644 --- a/docs/checking-translations.md +++ b/docs/checking-translations.md @@ -38,6 +38,64 @@ All checks run by default. Pass one or more check flags, such as `--missing-tran > [!NOTE] > `inlang validate` and `inlang lint` are deprecated. `inlang lint` does nothing and exits with `0`, so replace it with `inlang check` in your scripts and CI. +## Check while compiling + +To reject incomplete translations every time Paraglide compiles, enable +`checkTranslations` in `project.inlang/paraglide.config.ts`: + +```ts +export default { + checkTranslations: true, +}; +``` + +The CLI and bundler plugins load this configuration. You can also pass +`checkTranslations: true` directly to a plugin, `compile()`, or `compileProject()`. +The compiler uses the inlang SDK's translation checks on the bundles it has +already loaded, comparing all configured locales against `baseLocale`. It checks +missing and empty translations and forms, variables, markup, selectors, and +plural or select forms. It does not load a second project or start another process. + +Findings reject compilation before generated files are written. The CLI exits +with an error, and production bundler builds fail. During development, bundler +watchers report the error and keep the last successfully generated output; this +option does not block the dev server from serving that output. + +With esbuild, set `watchMode: true` on the plugin when using +`context().watch()` or `context().serve()`. esbuild does not expose these modes +to plugins, so they cannot be detected automatically: + +```ts +import * as esbuild from "esbuild"; +import { paraglideEsbuildPlugin } from "@inlang/paraglide-js"; + +const context = await esbuild.context({ + entryPoints: ["src/index.ts"], + bundle: true, + outdir: "dist", + plugins: [ + paraglideEsbuildPlugin({ + project: "./project.inlang", + outdir: "./src/paraglide", + checkTranslations: true, + watchMode: true, + }), + ], +}); + +await context.watch(); // Or: await context.serve(); +``` + +`watchMode` is a plugin option, not a compiler or `paraglide.config` option. +Leave it unset for automatic detection in other bundlers and for strict esbuild +builds. An explicit `false` makes the plugin's build-start hook strict even when +the bundler reports watch mode. `NODE_ENV=production` keeps that hook strict +regardless of `watchMode`. + +The default for `checkTranslations` is `false`, so projects that intentionally rely on locale fallback +keep their existing behavior. Use `inlang check` for unused-message analysis, +locale or check selection, or a machine-readable report. + ## Unused messages ```sh diff --git a/src/bundler-plugins/check-translations.test.ts b/src/bundler-plugins/check-translations.test.ts new file mode 100644 index 00000000..1e7717c6 --- /dev/null +++ b/src/bundler-plugins/check-translations.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import consola from "consola"; +import { memfs } from "memfs"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import type { UnpluginBuildContext, UnpluginOptions } from "unplugin"; +import { unpluginFactory } from "./unplugin.js"; +import { paraglideEsbuildPlugin } from "./esbuild.js"; + +beforeEach(() => { + consola.mockTypes(() => vi.fn()); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +async function createFixture(checkTranslations = true, watchMode?: boolean) { + const fs = memfs({ + "/project.inlang/settings.json": JSON.stringify({ + baseLocale: "en", + locales: ["en", "de"], + modules: ["./plugin.js"], + "plugin.inlang.messageFormat": { + pathPattern: "./messages/{locale}.json", + }, + }), + "/plugin.js": readFileSync( + createRequire(import.meta.url).resolve("@inlang/plugin-message-format"), + "utf8" + ), + "/messages/en.json": JSON.stringify({ hello: "Hello" }), + "/messages/de.json": "{}", + "/output/messages.js": "previous output", + }).fs as unknown as typeof import("node:fs"); + const options = { + project: "/project.inlang", + outdir: "/output", + checkTranslations, + watchMode, + emitReadme: false, + fs, + }; + const plugin = unpluginFactory(options) as UnpluginOptions; + return { fs, plugin, options }; +} + +async function esbuildStart( + options: Parameters[0] +) { + let start: (() => unknown) | undefined; + const plugin = paraglideEsbuildPlugin(options); + await plugin.setup({ + initialOptions: {}, + onStart: (callback: () => unknown) => { + start = callback; + }, + } as Parameters[0]); + expect(start).toBeTypeOf("function"); + return () => start!(); +} + +function context(watchMode = false): UnpluginBuildContext { + return { + addWatchFile: vi.fn(), + meta: { watchMode }, + } as unknown as UnpluginBuildContext; +} + +function beforeRun(plugin: UnpluginOptions, bundler: "webpack" | "rspack") { + let run: (() => Promise) | undefined; + const compiler = { + context: "/", + options: {}, + hooks: { + beforeRun: { + tapPromise: (_name: string, callback: () => Promise) => { + run = callback; + }, + }, + }, + }; + if (bundler === "webpack") { + plugin.webpack!( + compiler as unknown as Parameters< + NonNullable + >[0] + ); + } else { + plugin.rspack!( + compiler as unknown as Parameters< + NonNullable + >[0] + ); + } + expect(run).toBeTypeOf("function"); + return () => run!(); +} + +test.each([undefined, "development", "production"])( + "strict builds reject incomplete translations with NODE_ENV=%s", + async (nodeEnv) => { + vi.stubEnv("NODE_ENV", nodeEnv); + for (const hook of ["buildStart", "webpack", "rspack"] as const) { + const { plugin, fs } = await createFixture(); + const run = + hook === "buildStart" + ? () => plugin.buildStart!.call(context()) + : beforeRun(plugin, hook); + await expect(run()).rejects.toThrow(/missing-translation.*hello.*de/s); + expect(fs.readFileSync("/output/messages.js", "utf8")).toBe( + "previous output" + ); + } + } +); + +test.each(["rollup", "vite", "webpack", "rspack"])( + "%s watch contexts preserve output and recover after strict checks fail", + async (bundler) => { + vi.stubEnv("NODE_ENV", undefined); + const { plugin, fs } = await createFixture(); + const buildContext = context(bundler === "rollup"); + if (bundler === "vite") { + const hook = plugin.vite!.configResolved as { + handler: (config: { command: string }) => void; + }; + hook.handler({ command: "serve" }); + } else if (bundler === "webpack" || bundler === "rspack") { + buildContext.getNativeBuildContext = () => + ({ + framework: bundler, + compiler: { watchMode: true }, + }) as ReturnType< + NonNullable + >; + } + await expect( + plugin.buildStart!.call(buildContext) + ).resolves.toBeUndefined(); + expect(fs.readFileSync("/output/messages.js", "utf8")).toBe( + "previous output" + ); + await fs.promises.writeFile( + "/messages/de.json", + JSON.stringify({ hello: "Hallo" }) + ); + await plugin.watchChange!.call(buildContext, "/messages/de.json", { + event: "update", + }); + expect(fs.readFileSync("/output/messages.js", "utf8")).not.toBe( + "previous output" + ); + } +); + +test("builds still allow locale fallback when translation checks are disabled", async () => { + vi.stubEnv("NODE_ENV", undefined); + const { plugin, fs } = await createFixture(false); + await expect(plugin.buildStart!.call(context())).resolves.toBeUndefined(); + expect(fs.readFileSync("/output/messages.js", "utf8")).not.toBe( + "previous output" + ); +}); + +test.each([undefined, "development"])( + "esbuild watchMode preserves output and recovers with NODE_ENV=%s", + async (nodeEnv) => { + vi.stubEnv("NODE_ENV", nodeEnv); + const { fs, options } = await createFixture(true, true); + const run = await esbuildStart(options); + await expect(run()).resolves.toBeUndefined(); + expect(fs.readFileSync("/output/messages.js", "utf8")).toBe( + "previous output" + ); + await fs.promises.writeFile( + "/messages/de.json", + JSON.stringify({ hello: "Hallo" }) + ); + await expect(run()).resolves.toBeUndefined(); + const generated = fs.readFileSync("/output/messages.js", "utf8"); + const translated = fs.readFileSync("/output/messages/de.js", "utf8"); + expect(generated).not.toBe("previous output"); + expect(translated).toContain("Hallo"); + await fs.promises.writeFile("/messages/de.json", "{}"); + await expect(run()).resolves.toBeUndefined(); + expect(fs.readFileSync("/output/messages.js", "utf8")).toBe(generated); + expect(fs.readFileSync("/output/messages/de.js", "utf8")).toBe(translated); + await fs.promises.writeFile( + "/messages/de.json", + JSON.stringify({ hello: "Hallo again" }) + ); + await expect(run()).resolves.toBeUndefined(); + expect(fs.readFileSync("/output/messages/de.js", "utf8")).toContain( + "Hallo again" + ); + } +); + +test.each([undefined, false])( + "esbuild builds stay strict with watchMode=%s", + async (watchMode) => { + vi.stubEnv("NODE_ENV", undefined); + const { fs, options } = await createFixture(true, watchMode); + const run = await esbuildStart(options); + await expect(run()).rejects.toThrow(/missing-translation.*hello.*de/s); + expect(fs.readFileSync("/output/messages.js", "utf8")).toBe( + "previous output" + ); + } +); + +test("production esbuild builds stay strict with watchMode enabled", async () => { + vi.stubEnv("NODE_ENV", "production"); + const { options } = await createFixture(true, true); + const run = await esbuildStart(options); + await expect(run()).rejects.toThrow(/missing-translation.*hello.*de/s); +}); + +test("an explicit watchMode overrides bundler watch detection", async () => { + vi.stubEnv("NODE_ENV", undefined); + const { plugin } = await createFixture(true, false); + await expect(plugin.buildStart!.call(context(true))).rejects.toThrow( + /missing-translation.*hello.*de/s + ); +}); diff --git a/src/bundler-plugins/unplugin.ts b/src/bundler-plugins/unplugin.ts index bfcac654..789eee97 100644 --- a/src/bundler-plugins/unplugin.ts +++ b/src/bundler-plugins/unplugin.ts @@ -34,9 +34,9 @@ const PLUGIN_NAME = "unplugin-paraglide-js"; * Relative paths are resolved against the tool's project root (Vite `root`, * webpack/rspack `context`, else the process working directory). * - * Every other property is optional: values that are not provided are read - * from the config file, and anything still missing falls back to the - * built-in defaults. + * Every other property is optional. Compiler options that are not provided + * are read from the config file, and anything still missing falls back to + * the built-in defaults. `watchMode` is only accepted as a plugin option. * * @example * ```ts @@ -60,6 +60,14 @@ export type ParaglidePluginOptions = Omit< * `root`, webpack/rspack `context`, else the process working directory). */ project: string; + /** + * Override automatic watch-mode detection. Set to `true` when using + * esbuild's `context().watch()` or `context().serve()`, which do not + * expose their mode to plugins. Translation check failures then keep + * the last successfully generated output during development. + * `NODE_ENV=production` still makes compilation failures fatal. + */ + watchMode?: boolean; }; const logger = new Logger(); @@ -423,6 +431,16 @@ function maybeInvalidateForOptionsChange( state.previousOutdir = compilerOptions.outdir; } +function shouldFailBuild( + options: Pick, + watchMode: boolean +): boolean { + return ( + process.env.NODE_ENV === "production" || + (options.checkTranslations === true && !watchMode) + ); +} + // Compiles and stores the result plus its inputs digest on the plugin // state. Callers decide whether to persist the compilation cache. async function compileAndUpdateState(args: { @@ -460,6 +478,7 @@ async function compileAndUpdateState(args: { async function runCompilation(args: { state: PluginState; compilerOptions: CompilerOptions; + watchMode: boolean; }): Promise { const compilerOptions = args.compilerOptions; const { state } = args; @@ -519,7 +538,7 @@ async function runCompilation(args: { state.previousInputsDigest = undefined; logger.error("Failed to compile project:", (error as Error).message); logger.info("Please check your translation files for syntax errors."); - if (process.env.NODE_ENV === "production") throw error; + if (shouldFailBuild(compilerOptions, args.watchMode)) throw error; } } @@ -536,10 +555,11 @@ type UnpluginFactoryWithOptions = ( ) => ReturnType>; export const unpluginFactory: UnpluginFactoryWithOptions = ( - userArgs, + rawArgs, _meta, defaults ) => { + const { watchMode: explicitWatchMode, ...userArgs } = rawArgs; const state = getPluginState(userArgs); const { readFiles, clearReadFiles } = state; // Project root as reported by the bundler (vite `root`, webpack/rspack @@ -547,6 +567,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = ( // bundler doesn't provide one. The config file is resolved inside the // project directory. let discoveryRoot: string | undefined; + let isViteDevServer = false; // Bound to the hook context in buildStart so that watchChange can // register additional files. let addWatchFile: ((path: string) => void) | undefined; @@ -773,18 +794,25 @@ export const unpluginFactory: UnpluginFactoryWithOptions = ( if (isClosed) clearParaglideConfigCache({ projectDir: getProjectDir() }); isClosed = false; addWatchFile = this.addWatchFile?.bind(this); + const nativeContext = this.getNativeBuildContext?.(); + const watchMode = + explicitWatchMode ?? + (isViteDevServer || + (this as { meta?: { watchMode?: boolean } }).meta?.watchMode === + true || + ((nativeContext?.framework === "webpack" || + nativeContext?.framework === "rspack") && + nativeContext.compiler.watchMode === true)); try { const loaded = await loadActiveConfig(); const args = resolveOptionsWith(loaded); configLoadFailed = false; lastOptions = args; - await runCompilation({ state, compilerOptions: args }); + await runCompilation({ state, compilerOptions: args, watchMode }); } catch (error) { - // Config errors are converted to last-good options by - // loadActiveConfig; compilation errors are handled inside - // runCompilation. Anything escaping is unexpected — fail the - // build in production, keep serving in development. - if (process.env.NODE_ENV === "production") throw error; + // A strict non-watch build must fail even when the bundler + // leaves NODE_ENV unset or explicitly uses development mode. + if (shouldFailBuild(lastOptions ?? userArgs, watchMode)) throw error; logger.error( "Failed to prepare paraglide compilation:", (error as Error).message @@ -874,8 +902,9 @@ export const unpluginFactory: UnpluginFactoryWithOptions = ( }, }, configResolved: { - handler: (config: { root?: string }) => { + handler: (config: { root?: string; command?: string }) => { discoveryRoot = config.root; + isViteDevServer = config.command === "serve"; }, }, configureServer(server) { @@ -918,7 +947,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = ( "Failed to load paraglide config:", (error as Error).message ); - if (process.env.NODE_ENV === "production") throw error; + if (shouldFailBuild(lastOptions ?? userArgs, false)) throw error; return; } const outputStructure = resolveOutputStructure(args); @@ -973,7 +1002,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = ( state.previousInputsDigest = undefined; logger.warn("Failed to compile project:", (error as Error).message); logger.warn("Please check your translation files for syntax errors."); - if (process.env.NODE_ENV === "production") throw error; + if (shouldFailBuild(args, false)) throw error; } }); }, @@ -1005,7 +1034,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = ( "Failed to load paraglide config:", (error as Error).message ); - if (process.env.NODE_ENV === "production") throw error; + if (shouldFailBuild(lastOptions ?? userArgs, false)) throw error; return; } const outputStructure = resolveOutputStructure(args); @@ -1056,7 +1085,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = ( state.previousInputsDigest = undefined; logger.warn("Failed to compile project:", (error as Error).message); logger.warn("Please check your translation files for syntax errors."); - if (process.env.NODE_ENV === "production") throw error; + if (shouldFailBuild(args, false)) throw error; } }); }, diff --git a/src/bundler-plugins/vite-error-response.test.ts b/src/bundler-plugins/vite-error-response.test.ts index abef696a..21ea4f03 100644 --- a/src/bundler-plugins/vite-error-response.test.ts +++ b/src/bundler-plugins/vite-error-response.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest"; import fs from "node:fs/promises"; import nodeFs from "node:fs"; import { tmpdir } from "node:os"; +import { createRequire } from "node:module"; import path from "node:path"; import { createServer, type HotPayload, type ViteDevServer } from "vite"; import { @@ -14,7 +15,7 @@ import { paraglideVitePlugin } from "./vite.js"; type FixtureOptions = { devErrorResponse?: boolean; experimentalPerLocaleBuild?: boolean; - source: "settings" | "config"; + source: "settings" | "config" | "translations"; isInitiallyInvalid?: boolean; }; @@ -49,13 +50,48 @@ async function createFixture(options: FixtureOptions) { path.join(root, "index.html"), "

Translation preview

" ); + if (options.source === "translations") { + await fs.mkdir(path.join(root, "messages")); + await fs.writeFile( + path.join(root, "plugin.js"), + await fs.readFile( + createRequire(import.meta.url).resolve("@inlang/plugin-message-format"), + "utf8" + ) + ); + await fs.writeFile( + path.join(projectDir, "settings.json"), + JSON.stringify({ + baseLocale: "en", + locales: ["en", "de"], + modules: [path.join(root, "plugin.js")], + "plugin.inlang.messageFormat": { + pathPattern: path.join(root, "messages/{locale}.json"), + }, + }) + ); + await fs.writeFile( + path.join(root, "messages/en.json"), + JSON.stringify({ hello: "Hello" }) + ); + await fs.writeFile( + path.join(root, "messages/de.json"), + JSON.stringify({ hello: "Hallo" }) + ); + } const file = options.source === "settings" ? path.join(projectDir, "settings.json") - : configFile; + : options.source === "translations" + ? path.join(root, "messages/de.json") + : configFile; const original = await fs.readFile(file, "utf8"); const invalid = - options.source === "settings" ? "{" : "export default { outdir: 42 };"; + options.source === "settings" + ? "{" + : options.source === "translations" + ? "{}" + : "export default { outdir: 42 };"; if (options.isInitiallyInvalid) await fs.writeFile(file, invalid); const server = await createServer({ root, @@ -66,6 +102,7 @@ async function createFixture(options: FixtureOptions) { project: projectDir, outdir: path.join(root, "paraglide"), devErrorResponse: options.devErrorResponse, + checkTranslations: options.source === "translations", experimentalPerLocaleBuild: options.experimentalPerLocaleBuild, }), ], @@ -82,7 +119,7 @@ async function notifyChange(server: ViteDevServer, file: string) { await server.pluginContainer.watchChange(file, { event: "update" }); } -test.each(["settings", "config"] as const)( +test.each(["settings", "config", "translations"] as const)( "serves HTTP 500 for invalid %s on startup and recovers without restarting", async (source) => { const fixture = await createFixture({ @@ -103,7 +140,7 @@ test.each(["settings", "config"] as const)( } ); -test.each(["settings", "config"] as const)( +test.each(["settings", "config", "translations"] as const)( "blocks stale responses after invalid %s and restores them after recovery", async (source) => { const fixture = await createFixture({ source, devErrorResponse: true }); @@ -123,7 +160,7 @@ test.each(["settings", "config"] as const)( } ); -test.each(["settings", "config"] as const)( +test.each(["settings", "config", "translations"] as const)( "keeps the default HTTP behavior after invalid %s", async (source) => { const fixture = await createFixture({ source }); diff --git a/src/compiler/check-translations.test.ts b/src/compiler/check-translations.test.ts new file mode 100644 index 00000000..050b7f55 --- /dev/null +++ b/src/compiler/check-translations.test.ts @@ -0,0 +1,175 @@ +import { afterEach, expect, test, vi } from "vitest"; +import * as sdk from "@inlang/sdk"; +import { memfs } from "memfs"; +import { loadProjectInMemory, newProject } from "@inlang/sdk"; +import messageFormat from "@inlang/plugin-message-format"; +import * as v from "valibot"; +import { compileProject } from "./compile-project.js"; +import { compile } from "./compile.js"; +import { paraglideConfigSchema } from "../services/config/config-schema.js"; + +vi.mock("@inlang/sdk", async (importOriginal) => ({ + ...(await importOriginal()), + loadProjectFromDirectory: vi.fn(), +})); + +type Catalogs = Record>; +type TestProject = Awaited>; +const projects: TestProject[] = []; +const strictOptions = { checkTranslations: true, emitReadme: false }; + +async function createProject(catalogs: Catalogs): Promise { + const settings = { + baseLocale: "en", + locales: ["en", "pt", "es"], + modules: [], + }; + const project = await loadProjectInMemory({ + blob: await newProject({ settings }), + providePlugins: [ + { + key: messageFormat.key, + importFiles: ({ files }) => + messageFormat.importFiles!({ files, settings }), + }, + ], + }); + projects.push(project); + await project.importFiles({ + pluginKey: messageFormat.key, + files: Object.entries(catalogs).map(([locale, messages]) => ({ + locale, + content: new TextEncoder().encode(JSON.stringify(messages)), + })), + }); + return project; +} + +afterEach(async () => { + vi.restoreAllMocks(); + for (const project of projects.splice(0)) await project.close(); +}); + +test("translation checks remain opt-in and preserve fallback output", async () => { + const project = await createProject({ en: { hello: "Hello {name}" } }); + const implicit = await compileProject({ project }); + const compilerOptions = { checkTranslations: false, emitReadme: true }; + expect(await compileProject({ project, compilerOptions })).toEqual(implicit); + await expect( + compileProject({ project, compilerOptions: strictOptions }) + ).rejects.toThrow(/missing-translation.*hello.*pt/s); +}); + +test.each([ + ["empty-translation", " "], + ["missing-variable", "Hola"], + ["unknown-variable", "Hola {username}"], +])("reports %s with the bundle ID and locale", async (checkId, translation) => { + const project = await createProject({ + en: { hello: "Hello {name}" }, + pt: { hello: "Olá {name}" }, + es: { hello: translation }, + }); + await expect( + compileProject({ project, compilerOptions: strictOptions }) + ).rejects.toThrow(new RegExp(`${checkId}.*hello.*es`, "s")); +}); + +test("valid translations produce the same files with checks enabled", async () => { + const project = await createProject({ + en: { hello: "Hello {name}" }, + pt: { hello: "Olá {name}" }, + es: { hello: "Hola {name}" }, + }); + expect( + await compileProject({ project, compilerOptions: strictOptions }) + ).toEqual( + await compileProject({ project, compilerOptions: { emitReadme: false } }) + ); +}); + +test("validates every plural form using the SDK semantics", async () => { + const plural = [ + { + declarations: [ + "input count", + "input name", + "local plural = count: plural", + ], + selectors: ["plural"], + match: { + "plural=one": "{name}: {count} item", + "plural=*": "{name}: {count} items", + }, + }, + ]; + const project = await createProject({ + en: { items: plural }, + pt: { items: plural }, + es: { + items: [ + { + ...plural[0], + match: { + "plural=one": "{name}: {count} item", + "plural=*": "{count} items", + }, + }, + ], + }, + }); + await expect( + compileProject({ project, compilerOptions: strictOptions }) + ).rejects.toThrow(/missing-variable.*items.*name.*es/s); +}); + +test("config accepts checkTranslations as a boolean", () => { + expect( + v.parse(paraglideConfigSchema, { checkTranslations: true }) + ).toMatchObject({ checkTranslations: true }); + expect( + v.safeParse(paraglideConfigSchema, { checkTranslations: "true" }).success + ).toBe(false); +}); + +test.each([false, true])( + "failed checks preserve output and release the lock when close also fails: %s", + async (closeFails) => { + const invalid = await createProject({ en: { hello: "Hello" } }); + const valid = await createProject({ + en: { hello: "Hello" }, + pt: { hello: "Olá" }, + es: { hello: "Hola" }, + }); + const close = invalid.close; + const closeSpy = vi.spyOn(invalid, "close").mockImplementation(async () => { + await close(); + projects.splice(projects.indexOf(invalid), 1); + if (closeFails) throw new Error("cleanup failed"); + }); + vi.spyOn(sdk, "loadProjectFromDirectory") + .mockResolvedValueOnce({ + ...invalid, + errors: { get: async () => [...(await invalid.errors.get())] }, + }) + .mockResolvedValueOnce({ + ...valid, + errors: { get: async () => [...(await valid.errors.get())] }, + }); + const { fs, vol } = memfs({ "/output/messages.js": "previous output" }); + const options = { + ...strictOptions, + project: "/project.inlang", + outdir: "/output", + fs: fs as unknown as typeof import("node:fs"), + }; + await expect(compile(options)).rejects.toThrow(/missing-translation/); + expect(closeSpy).toHaveBeenCalledOnce(); + expect(vol.toJSON()).toEqual({ "/output/messages.js": "previous output" }); + await expect(compile(options)).resolves.toHaveProperty("outputHashes"); + projects.splice(projects.indexOf(valid), 1); + expect(fs.readFileSync("/output/messages.js", "utf8")).not.toBe( + "previous output" + ); + } +); diff --git a/src/compiler/compile-project.ts b/src/compiler/compile-project.ts index 2bf90661..26cd0951 100644 --- a/src/compiler/compile-project.ts +++ b/src/compiler/compile-project.ts @@ -1,5 +1,9 @@ import { compileBundle } from "./compile-bundle.js"; -import { selectBundleNested, type InlangProject } from "@inlang/sdk"; +import { + checkBundle, + selectBundleNested, + type InlangProject, +} from "@inlang/sdk"; import { lookup } from "../services/lookup.js"; import * as localeModules from "./output-structure/locale-modules.js"; import * as messageModules from "./output-structure/message-modules.js"; @@ -43,6 +47,22 @@ export const compileProject = async (args: { const settings = await args.project.settings.get(); const bundles = await selectBundleNested(args.project.db).execute(); + if (optionsWithDefaults.checkTranslations) { + const diagnostics = bundles.flatMap((bundle) => + checkBundle({ + bundle, + locales: settings.locales, + referenceLocale: settings.baseLocale, + }) + ); + if (diagnostics.length > 0) { + throw new Error( + `Translation checks failed:\n${diagnostics + .map((diagnostic) => `${diagnostic.checkId}: ${diagnostic.message}`) + .join("\n")}` + ); + } + } //Maps each language to it's fallback //If there is no fallback, it will be undefined diff --git a/src/compiler/compiler-options.ts b/src/compiler/compiler-options.ts index 8a468af9..2ccc5ebb 100644 --- a/src/compiler/compiler-options.ts +++ b/src/compiler/compiler-options.ts @@ -38,6 +38,7 @@ export const defaultCompilerOptions = { emitReadme: true, emitTsDeclarations: false, cleanOutdir: true, + checkTranslations: false, disableAsyncLocalStorage: false, experimentalMiddlewareLocaleSplitting: false, localStorageKey: "PARAGLIDE_LOCALE", @@ -49,6 +50,16 @@ export const defaultCompilerOptions = { } as const satisfies Partial; export type CompilerOptions = { + /** + * Validate translations with the Inlang SDK before compiling messages. + * Reports missing or empty translations and forms, variables, markup, + * selectors, and plural/select forms. Diagnostics fail compilation before + * generated files are written. Unused messages require `inlang check`. + * + * Disabled by default to preserve intentional locale fallback. + * @default false + */ + checkTranslations?: boolean; /** * The path to the inlang project. * diff --git a/src/services/config/config-schema.ts b/src/services/config/config-schema.ts index 8931c1fc..b2b33c55 100644 --- a/src/services/config/config-schema.ts +++ b/src/services/config/config-schema.ts @@ -125,6 +125,7 @@ export const paraglideConfigSchema = v.object({ v.picklist(["message-modules", "locale-modules"]) ), cleanOutdir: v.optional(v.boolean()), + checkTranslations: v.optional(v.boolean()), }); // Fails to compile when the schema and the compiler options drift apart: