From 4094f6208d0ebbb1970f78150a492984ad290107 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 20 Jul 2026 18:39:58 +0200 Subject: [PATCH] fix(web): load the inlang plugin from node_modules, not a CDN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `web/project.inlang/settings.json` pointed `modules` at https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js — inlang's stock `paraglide-js init` scaffolding, which nobody had a reason to keep. It made `bun run codegen` need the network at build time, so in a sandbox that has none (the Nix build; any air-gapped CI) the plugin import failed and paraglide compiled ZERO messages. That failure is silent by design: the inlang SDK logs a PluginImportError as a WARNING, paraglide still prints "Successfully compiled" and exits 0, vite bundles the empty message set, and the console only dies at request time inside renderToReadableStream because every `m.foo()` is undefined. Reported from a NixOS host running punktfunk-web-server. The plugin is a normal npm package, so depend on it like one: add @inlang/plugin-message-format as a devDependency and reference it by relative path. `loadProjectFromDirectory` (which both `paraglide-js compile` and the vite plugin use) imports non-`http` modules straight off disk, and the shipped dist/index.js is a self-contained bundle with no imports of its own, so it loads from the data: URL exactly as the CDN copy did. Compiles 268 messages offline, with no project.inlang/cache round-trip. Add tools/check-i18n.mjs, run at the end of `codegen`, so this can never ship silently again: it fails the build on a remote `modules` entry, a module that does not resolve, or a compiled message count below what messages/en.json defines. Verified it catches all three (remote URL, missing plugin, corrupt plugin that compiles to 0 messages) and passes a healthy build. Co-Authored-By: Claude Opus 4.8 (1M context) --- packaging/nix/packages.nix | 10 +++- web/bun.lock | 5 ++ web/package.json | 3 +- web/project.inlang/settings.json | 4 +- web/tools/check-i18n.mjs | 80 ++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 web/tools/check-i18n.mjs diff --git a/packaging/nix/packages.nix b/packaging/nix/packages.nix index 1215162d..deb5f061 100644 --- a/packaging/nix/packages.nix +++ b/packaging/nix/packages.nix @@ -313,7 +313,7 @@ in dontFixup = true; outputHashMode = "recursive"; outputHashAlgo = "sha256"; - outputHash = "sha256-OA4NjwapsCV/z+0rftDCMAQJGWw63Mi/GARetmuy0QU="; # web/bun.lock deps (refresh on lockfile change; see README). + outputHash = "sha256-5oVZv65SMvq9i2REzHE8Pyn6qUZaV2FnPQdaouwcwoU="; # web/bun.lock deps (refresh on lockfile change; see README). }; in stdenvNoCC.mkDerivation { @@ -326,6 +326,14 @@ in # No cross-derivation dep cache: codegen + the vite build are fully offline (every input is in # the vendored node_modules, the checked-in api/openapi.json, and web/project.inlang). + # + # ⚠ "Offline" is load-bearing and NOT self-enforcing. inlang resolves the plugins in + # web/project.inlang/settings.json `modules`, and a failed import is only a WARNING there: + # paraglide then prints "Successfully compiled", exits 0, and emits ZERO messages, so the + # console builds fine and dies at SSR time with every `m.foo()` undefined. That is exactly + # what a CDN URL in `modules` did in this network-less sandbox. The plugin is now a normal + # devDependency referenced by path, and `bun run codegen` ends in tools/check-i18n.mjs, which + # fails the build on a remote module or a short message count. Keep both properties. buildPhase = '' runHook preBuild export HOME=$TMPDIR diff --git a/web/bun.lock b/web/bun.lock index 1f9bf28b..91922815 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -25,6 +25,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.2", "@inlang/paraglide-js": "^2.20.2", + "@inlang/plugin-message-format": "^4.4.0", "@storybook/react-vite": "^10.4.6", "@tailwindcss/vite": "^4.3.2", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", @@ -283,6 +284,8 @@ "@inlang/paraglide-js": ["@inlang/paraglide-js@2.20.2", "", { "dependencies": { "@inlang/recommend-sherlock": "^0.2.1", "@inlang/sdk": "^2.10.0", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", "unplugin": "^2.1.2", "urlpattern-polyfill": "^10.0.0" }, "peerDependencies": { "typescript": ">=5.6" }, "optionalPeers": ["typescript"], "bin": { "paraglide-js": "bin/run.js" } }, "sha512-V8iY3uu/vQU94gEag1bdC3glMJSp4Dg3XMwfnabZLBh1Dv0F++DvDYlMeniqv2+nHbnS/twB75AM140OmpHDEg=="], + "@inlang/plugin-message-format": ["@inlang/plugin-message-format@4.4.0", "", { "dependencies": { "flat": "^6.0.1" } }, "sha512-n4aXt6XVg5kxhKoLAhi9nMgZtCA9iS0QOaXte56VqxWHcfj9O4c4gOkyVQZH7H9D8h7OZufCrO1sZGYOypPwEA=="], + "@inlang/recommend-sherlock": ["@inlang/recommend-sherlock@0.2.1", "", { "dependencies": { "comment-json": "^4.2.3" } }, "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg=="], "@inlang/sdk": ["@inlang/sdk@2.10.2", "", { "dependencies": { "@lix-js/sdk": "0.4.10", "@sinclair/typebox": "^0.31.17", "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", "uuid": "^14.0.0" } }, "sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw=="], @@ -1383,6 +1386,8 @@ "find-up": ["find-up@8.0.0", "", { "dependencies": { "locate-path": "^8.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww=="], + "flat": ["flat@6.0.1", "", { "bin": { "flat": "cli.js" } }, "sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw=="], + "focus-trap": ["focus-trap@7.5.4", "", { "dependencies": { "tabbable": "^6.2.0" } }, "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], diff --git a/web/package.json b/web/package.json index d8b5c7f2..efff90e1 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,7 @@ "description": "punktfunk management console — TanStack Start + React Query (orval) + @unom/ui + Paraglide i18n", "scripts": { "prepare": "bun run codegen", - "codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide", + "codegen": "orval --config orval.config.ts && paraglide-js compile --project ./project.inlang --outdir ./src/paraglide && node tools/check-i18n.mjs", "predev": "orval --config orval.config.ts", "dev": "vite dev --port 47992", "prebuild": "orval --config orval.config.ts", @@ -39,6 +39,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.2", "@inlang/paraglide-js": "^2.20.2", + "@inlang/plugin-message-format": "^4.4.0", "@storybook/react-vite": "^10.4.6", "@tailwindcss/vite": "^4.3.2", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", diff --git a/web/project.inlang/settings.json b/web/project.inlang/settings.json index a4a58122..df26186a 100644 --- a/web/project.inlang/settings.json +++ b/web/project.inlang/settings.json @@ -2,9 +2,7 @@ "$schema": "https://inlang.com/schema/project-settings", "baseLocale": "en", "locales": ["en", "de"], - "modules": [ - "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js" - ], + "modules": ["./node_modules/@inlang/plugin-message-format/dist/index.js"], "plugin.inlang.messageFormat": { "pathPattern": "./messages/{locale}.json" } diff --git a/web/tools/check-i18n.mjs b/web/tools/check-i18n.mjs new file mode 100644 index 00000000..08e77c6a --- /dev/null +++ b/web/tools/check-i18n.mjs @@ -0,0 +1,80 @@ +// Guard: assert paraglide actually compiled our translations. +// +// The inlang SDK treats a failed plugin import as a WARNING, not an error: if the +// message-format plugin can't be loaded, `paraglide-js compile` still prints +// "Successfully compiled" and exits 0 — having emitted an empty message set. vite +// then happily bundles that, and the console only dies at SSR time, deep inside +// renderToReadableStream, because every `m.foo()` is undefined. That shipped once +// (a Nix build, where the plugin was fetched from a CDN in a network-off sandbox), +// so the build now fails loudly instead. +// +// node tools/check-i18n.mjs # run from web/, after `paraglide-js compile` +// +// Env knobs: PARAGLIDE_OUTDIR (default ./src/paraglide), INLANG_PROJECT (default +// ./project.inlang). + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const outdir = resolve(process.env.PARAGLIDE_OUTDIR ?? "./src/paraglide"); +const project = resolve(process.env.INLANG_PROJECT ?? "./project.inlang"); + +const fail = (msg) => { + console.error(`ERROR: i18n check failed — ${msg}`); + process.exit(1); +}; + +const settingsPath = join(project, "settings.json"); +if (!existsSync(settingsPath)) fail(`no inlang settings at ${settingsPath}`); +const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + +// Every plugin must resolve offline. A remote module makes the build depend on a CDN +// at compile time, which silently yields zero messages in any sandbox without network +// (Nix, air-gapped CI) — see the header. +for (const mod of settings.modules ?? []) { + if (/^https?:/i.test(mod)) { + fail( + `inlang module "${mod}" is a remote URL — vendor it as a devDependency and reference it by path, ` + + `else offline builds compile zero messages`, + ); + } + const modPath = resolve(project, "..", mod); + if (!existsSync(modPath)) + fail( + `inlang module "${mod}" does not exist at ${modPath} (run \`bun install\`?)`, + ); +} + +// The base-locale source file is the source of truth for how many messages we expect. +const pathPattern = settings["plugin.inlang.messageFormat"]?.pathPattern; +if (!pathPattern) + fail("settings.json has no plugin.inlang.messageFormat.pathPattern"); +const basePath = resolve( + project, + "..", + pathPattern.replace("{locale}", settings.baseLocale), +); +if (!existsSync(basePath)) fail(`base-locale messages missing at ${basePath}`); +const expected = Object.keys(JSON.parse(readFileSync(basePath, "utf8"))).filter( + (k) => !k.startsWith("$"), +).length; +if (expected === 0) fail(`base-locale messages at ${basePath} are empty`); + +const messagesDir = join(outdir, "messages"); +if (!existsSync(messagesDir)) + fail(`paraglide emitted no messages dir at ${messagesDir}`); +// paraglide writes one module per message plus an `_index.js` barrel. +const emitted = readdirSync(messagesDir).filter( + (f) => f.endsWith(".js") && f !== "_index.js", +).length; + +if (emitted < expected) { + fail( + `paraglide emitted ${emitted} messages but ${basePath} defines ${expected}. ` + + `The message-format plugin most likely failed to import — check the compile output for PluginImportError.`, + ); +} + +console.log( + `✔ i18n check: ${emitted} compiled messages for ${settings.locales.join(", ")}`, +);