From f5675792f60749c3d6e85388c46d53fd3077193c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 11 Oct 2026 03:59:20 -0300 Subject: [PATCH 1/3] feat: add opt-in translation checks during compilation --- .changeset/native-translation-checks.md | 5 + docs-api/compiler-options.md | 71 ++++++---- docs/checking-translations.md | 27 ++++ src/compiler/check-translations.test.ts | 171 ++++++++++++++++++++++++ src/compiler/compile-project.ts | 22 ++- src/compiler/compile.ts | 40 +++--- src/compiler/compiler-options.ts | 11 ++ src/services/config/config-schema.ts | 1 + 8 files changed, 304 insertions(+), 44 deletions(-) create mode 100644 .changeset/native-translation-checks.md create mode 100644 src/compiler/check-translations.test.ts 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 2838e6bd..4218dc11 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:238](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:249](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:400](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:411](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` **disableAsyncLocalStorage**: `boolean` -Defined in: [compiler-options.ts:329](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:340](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) Replaces AsyncLocalStorage with a synchronous implementation. @@ -128,7 +147,7 @@ one request could leak into another concurrent request. > `optional` **emitGitIgnore**: `boolean` -Defined in: [compiler-options.ts:343](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:354](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`. @@ -150,7 +169,7 @@ true > `optional` **emitPrettierIgnore**: `boolean` -Defined in: [compiler-options.ts:252](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:263](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`. @@ -172,7 +191,7 @@ true > `optional` **emitReadme**: `boolean` -Defined in: [compiler-options.ts:269](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:280](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`. @@ -197,7 +216,7 @@ true > `optional` **emitTsDeclarations**: `boolean` -Defined in: [compiler-options.ts:298](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:309](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. @@ -235,7 +254,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. @@ -259,7 +278,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. @@ -303,7 +322,7 @@ https://github.com/opral/paraglide-js/issues/88#issuecomment-3634754638 > `optional` **fs**: `any` -Defined in: [compiler-options.ts:407](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:418](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The file system to use. Defaults to `await import('node:fs')`. @@ -313,7 +332,7 @@ Useful for testing the paraglide compiler by mocking the fs. > `optional` **includeEslintDisableComment**: `boolean` -Defined in: [compiler-options.ts:316](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:327](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. @@ -327,7 +346,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. @@ -351,7 +370,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. @@ -365,7 +384,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. @@ -382,7 +401,7 @@ await compile({ > `optional` **outputStructure**: `"locale-modules"` \| `"message-modules"` -Defined in: [compiler-options.ts:394](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:405](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) The `outputStructure` defines how modules are structured in the output. @@ -441,7 +460,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. @@ -458,7 +477,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. @@ -481,7 +500,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. @@ -507,7 +526,7 @@ Custom strategies with the pattern `custom-[A-Za-z0-9_-]+` are supported. > `optional` **trailingSlash**: `"always"` \| `"never"` -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) Controls trailing slash canonicalization for localized URLs. @@ -519,7 +538,7 @@ behavior. > `optional` **urlPatterns**: [`Runtime`](runtime/type/README.md#runtime)\[`"urlPatterns"`\] -Defined in: [compiler-options.ts:302](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) +Defined in: [compiler-options.ts:313](https://github.com/opral/paraglide-js/tree/main/src/compiler/compiler-options.ts) https://paraglidejs.com/strategy#url @@ -583,6 +602,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..d02b69b6 100644 --- a/docs/checking-translations.md +++ b/docs/checking-translations.md @@ -38,6 +38,33 @@ 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. + +The default 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/compiler/check-translations.test.ts b/src/compiler/check-translations.test.ts new file mode 100644 index 00000000..a718c6c3 --- /dev/null +++ b/src/compiler/check-translations.test.ts @@ -0,0 +1,171 @@ +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("failed checks preserve output, close the project, and release the compilation lock", async () => { + 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); + }); + 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 d14d64db..87f4dba3 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/compile.ts b/src/compiler/compile.ts index 18166cd3..28908404 100644 --- a/src/compiler/compile.ts +++ b/src/compiler/compile.ts @@ -60,29 +60,31 @@ export async function compile( fs, }); - const output = await compileProject({ - compilerOptions: withDefaultOptions, - project, - projectPath: withDefaultOptions.project, - }); + try { + const output = await compileProject({ + compilerOptions: withDefaultOptions, + project, + projectPath: withDefaultOptions.project, + }); - const outputHashes = await writeOutput({ - directory: absoluteOutdir, - output, - cleanDirectory: withDefaultOptions.cleanOutdir, - fs: fs.promises, - previousOutputHashes: options.previousCompilation?.outputHashes, - }); + const outputHashes = await writeOutput({ + directory: absoluteOutdir, + output, + cleanDirectory: withDefaultOptions.cleanOutdir, + fs: fs.promises, + previousOutputHashes: options.previousCompilation?.outputHashes, + }); - const warningsAndErrors = await project.errors.get(); + const warningsAndErrors = await project.errors.get(); - for (const warningOrError of warningsAndErrors) { - logger.warn(warningOrError); - } + for (const warningOrError of warningsAndErrors) { + logger.warn(warningOrError); + } - await project.close(); - - return { outputHashes }; + return { outputHashes }; + } finally { + await project.close(); + } } catch (e) { // release the lock in case of an error compilationInProgress = null; diff --git a/src/compiler/compiler-options.ts b/src/compiler/compiler-options.ts index 3b9b5f79..d6e0a3d5 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 400fb291..909f9cf1 100644 --- a/src/services/config/config-schema.ts +++ b/src/services/config/config-schema.ts @@ -123,6 +123,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: From cbfece185538fb3a2214a1670a95deacf26b6298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 11 Oct 2026 08:18:58 -0300 Subject: [PATCH 2/3] fix: propagate translation checks in non-watch builds --- .../check-translations.test.ts | 147 ++++++++++++++++++ src/bundler-plugins/unplugin.ts | 42 +++-- 2 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 src/bundler-plugins/check-translations.test.ts diff --git a/src/bundler-plugins/check-translations.test.ts b/src/bundler-plugins/check-translations.test.ts new file mode 100644 index 00000000..1a3c49d1 --- /dev/null +++ b/src/bundler-plugins/check-translations.test.ts @@ -0,0 +1,147 @@ +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"; + +beforeEach(() => { + consola.mockTypes(() => vi.fn()); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +async function createFixture(checkTranslations = true) { + 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 plugin = unpluginFactory({ + project: "/project.inlang", + outdir: "/output", + checkTranslations, + emitReadme: false, + fs, + }) as UnpluginOptions; + return { fs, plugin }; +} + +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" + ); +}); diff --git a/src/bundler-plugins/unplugin.ts b/src/bundler-plugins/unplugin.ts index a8592ea8..3d1a1223 100644 --- a/src/bundler-plugins/unplugin.ts +++ b/src/bundler-plugins/unplugin.ts @@ -420,6 +420,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: { @@ -457,6 +467,7 @@ async function compileAndUpdateState(args: { async function runCompilation(args: { state: PluginState; compilerOptions: CompilerOptions; + watchMode: boolean; }): Promise { const compilerOptions = args.compilerOptions; const { state } = args; @@ -513,7 +524,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; } } @@ -531,6 +542,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = (userArgs) => { // 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; @@ -698,17 +710,22 @@ export const unpluginFactory: UnpluginFactoryWithOptions = (userArgs) => { enforce: "pre", async buildStart() { addWatchFile = this.addWatchFile?.bind(this); + const nativeContext = this.getNativeBuildContext?.(); + const watchMode = + 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); 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 @@ -768,8 +785,9 @@ export const unpluginFactory: UnpluginFactoryWithOptions = (userArgs) => { }, }, configResolved: { - handler: (config: { root?: string }) => { + handler: (config: { root?: string; command?: string }) => { discoveryRoot = config.root; + isViteDevServer = config.command === "serve"; }, }, }, @@ -797,7 +815,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = (userArgs) => { "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); @@ -852,7 +870,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = (userArgs) => { 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; } }); }, @@ -884,7 +902,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = (userArgs) => { "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); @@ -935,7 +953,7 @@ export const unpluginFactory: UnpluginFactoryWithOptions = (userArgs) => { 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; } }); }, From e0cf6b993a3637e1747edbe7a88dac6716a9990e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sun, 11 Oct 2026 09:18:11 -0300 Subject: [PATCH 3/3] fix: preserve translation output in esbuild watch mode --- docs/checking-translations.md | 33 ++++++- .../check-translations.test.ts | 88 ++++++++++++++++++- src/bundler-plugins/unplugin.ts | 31 +++++-- 3 files changed, 138 insertions(+), 14 deletions(-) diff --git a/docs/checking-translations.md b/docs/checking-translations.md index d02b69b6..4aa1593b 100644 --- a/docs/checking-translations.md +++ b/docs/checking-translations.md @@ -61,7 +61,38 @@ 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. -The default is `false`, so projects that intentionally rely on locale fallback +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. diff --git a/src/bundler-plugins/check-translations.test.ts b/src/bundler-plugins/check-translations.test.ts index 1a3c49d1..1e7717c6 100644 --- a/src/bundler-plugins/check-translations.test.ts +++ b/src/bundler-plugins/check-translations.test.ts @@ -5,6 +5,7 @@ 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()); @@ -15,7 +16,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -async function createFixture(checkTranslations = true) { +async function createFixture(checkTranslations = true, watchMode?: boolean) { const fs = memfs({ "/project.inlang/settings.json": JSON.stringify({ baseLocale: "en", @@ -33,14 +34,31 @@ async function createFixture(checkTranslations = true) { "/messages/de.json": "{}", "/output/messages.js": "previous output", }).fs as unknown as typeof import("node:fs"); - const plugin = unpluginFactory({ + const options = { project: "/project.inlang", outdir: "/output", checkTranslations, + watchMode, emitReadme: false, fs, - }) as UnpluginOptions; - return { fs, plugin }; + }; + 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 { @@ -145,3 +163,65 @@ test("builds still allow locale fallback when translation checks are disabled", "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 3d1a1223..2954250f 100644 --- a/src/bundler-plugins/unplugin.ts +++ b/src/bundler-plugins/unplugin.ts @@ -33,9 +33,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 @@ -59,6 +59,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(); @@ -534,7 +542,10 @@ type UnpluginFactoryWithOptions = ( userArgs: ParaglidePluginOptions ) => ReturnType>; -export const unpluginFactory: UnpluginFactoryWithOptions = (userArgs) => { +export const unpluginFactory: UnpluginFactoryWithOptions = ({ + watchMode: explicitWatchMode, + ...userArgs +}) => { const state = getPluginState(userArgs); const { readFiles, clearReadFiles } = state; // Project root as reported by the bundler (vite `root`, webpack/rspack @@ -712,11 +723,13 @@ export const unpluginFactory: UnpluginFactoryWithOptions = (userArgs) => { addWatchFile = this.addWatchFile?.bind(this); const nativeContext = this.getNativeBuildContext?.(); const watchMode = - isViteDevServer || - (this as { meta?: { watchMode?: boolean } }).meta?.watchMode === true || - ((nativeContext?.framework === "webpack" || - nativeContext?.framework === "rspack") && - nativeContext.compiler.watchMode === true); + 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);