From 18c3bfd5819a1ca0ca2cb31864e47aa44336e2e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:03:52 +0200 Subject: [PATCH 01/24] Make shared-webapp build plugins ESM-safe --- application/shared-webapp/build/platformSettings.ts | 2 +- .../shared-webapp/build/plugin/ModuleFederationPlugin.ts | 2 +- .../shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts | 2 +- application/shared-webapp/build/plugin/TailwindPlugin.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/application/shared-webapp/build/platformSettings.ts b/application/shared-webapp/build/platformSettings.ts index 94fe363f97..6c43f0b564 100644 --- a/application/shared-webapp/build/platformSettings.ts +++ b/application/shared-webapp/build/platformSettings.ts @@ -68,7 +68,7 @@ export interface PlatformSettings { // platform-settings.jsonc is the single source of truth for brand configuration, shared by the // backend (embedded resource) and the frontend (injected here at build time). It lives at the // application root; this file compiles to shared-webapp/build/dist/, three levels below it. -const settingsPath = path.join(__dirname, "..", "..", "..", "platform-settings.jsonc"); +const settingsPath = path.join(import.meta.dirname, "..", "..", "..", "platform-settings.jsonc"); export function loadPlatformSettings(): PlatformSettings { const raw = fs.readFileSync(settingsPath, "utf8"); diff --git a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts index 2491f0db90..30a8372f69 100644 --- a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts +++ b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts @@ -14,7 +14,7 @@ if (!fs.existsSync(applicationPackageJson)) { throw new Error(`Cannot find package.json in the application root: ${applicationRoot}`); } -const { dependencies } = require(applicationPackageJson); +const { dependencies } = JSON.parse(fs.readFileSync(applicationPackageJson, "utf-8")); const SYSTEM_ID = getSystemId(); diff --git a/application/shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts b/application/shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts index 0c02f0b47a..488b5316f1 100644 --- a/application/shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts +++ b/application/shared-webapp/build/plugin/RunTimeEnvironmentPlugin.ts @@ -28,7 +28,7 @@ export function RunTimeEnvironmentPlugin> source: { entry: { // Add the runtime environment file as the first entry point - index: [path.join(__dirname, "..", "environment", "runtime.js"), "./main.tsx"] + index: [path.join(import.meta.dirname, "..", "environment", "runtime.js"), "./main.tsx"] }, // Define the runtime environment variables as part of import.meta.* // The method getApplicationEnvironment() is defined in the runtime diff --git a/application/shared-webapp/build/plugin/TailwindPlugin.ts b/application/shared-webapp/build/plugin/TailwindPlugin.ts index 94b97f5868..8e720c8a81 100644 --- a/application/shared-webapp/build/plugin/TailwindPlugin.ts +++ b/application/shared-webapp/build/plugin/TailwindPlugin.ts @@ -8,7 +8,7 @@ import path from "node:path"; /** * Path to the shared Tailwind CSS styles file. */ -const tailwindStyleFilename = path.resolve(__dirname, "..", "..", "..", "ui", "tailwind.css"); +const tailwindStyleFilename = path.resolve(import.meta.dirname, "..", "..", "..", "ui", "tailwind.css"); if (fs.existsSync(tailwindStyleFilename) === false) { // This is a critical error, so we exit the process letting the developer know. From 06a52c7c4df9b14016134365d04eff5cc0df5824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:04:13 +0200 Subject: [PATCH 02/24] Convert i18n.config.json to i18n.config.ts --- .../routes/-components/LoginFooterControls.tsx | 2 +- .../components/-components/EmailsPreview.tsx | 2 +- .../components/-components/PreviewAvatarMenu.tsx | 2 +- .../shared/components/BackOfficeAvatarMenu.tsx | 2 +- application/account/WebApp/emails/lingui.config.ts | 2 +- .../federated-modules/common/LocaleSwitcher.tsx | 2 +- .../preferences/-components/usePreferences.tsx | 2 +- application/shared-webapp/emails/build/build.ts | 2 +- application/shared-webapp/emails/lingui.factory.ts | 2 +- .../infrastructure/translations/Translation.tsx | 2 +- .../translations/createLinguiConfig.ts | 2 +- .../infrastructure/translations/i18n.config.json | 14 -------------- .../infrastructure/translations/i18n.config.ts | 8 ++++++++ 13 files changed, 19 insertions(+), 25 deletions(-) delete mode 100644 application/shared-webapp/infrastructure/translations/i18n.config.json create mode 100644 application/shared-webapp/infrastructure/translations/i18n.config.ts diff --git a/application/account/BackOffice/routes/-components/LoginFooterControls.tsx b/application/account/BackOffice/routes/-components/LoginFooterControls.tsx index 755fa00ed3..7a478f5ed7 100644 --- a/application/account/BackOffice/routes/-components/LoginFooterControls.tsx +++ b/application/account/BackOffice/routes/-components/LoginFooterControls.tsx @@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Button } from "@repo/ui/components/Button"; import { diff --git a/application/account/BackOffice/routes/components/-components/EmailsPreview.tsx b/application/account/BackOffice/routes/components/-components/EmailsPreview.tsx index 191361bf9d..277c8a14b5 100644 --- a/application/account/BackOffice/routes/components/-components/EmailsPreview.tsx +++ b/application/account/BackOffice/routes/components/-components/EmailsPreview.tsx @@ -17,7 +17,7 @@ export function EmailsPreview() { // The iframe locale follows the SPA's active locale (set by the avatar-menu locale switcher) so // designers don't have to toggle locales twice. Falls back to en-US if the user's chosen locale // doesn't have a rendered email artifact yet — the email build only emits the locales listed in - // application/shared-webapp/infrastructure/translations/i18n.config.json that have .po catalogs. + // application/shared-webapp/infrastructure/translations/i18n.config.ts that have .po catalogs. const locale: PreviewLocale = (SUPPORTED_PREVIEW_LOCALES as readonly string[]).includes(i18n.locale) ? (i18n.locale as PreviewLocale) : FALLBACK_PREVIEW_LOCALE; diff --git a/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx b/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx index 266089e776..0c18038cd0 100644 --- a/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx +++ b/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx @@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Avatar, AvatarFallback } from "@repo/ui/components/Avatar"; import { diff --git a/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx b/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx index 9f49834320..eb17e5e3f3 100644 --- a/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx +++ b/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx @@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Avatar, AvatarFallback } from "@repo/ui/components/Avatar"; import { Badge } from "@repo/ui/components/Badge"; diff --git a/application/account/WebApp/emails/lingui.config.ts b/application/account/WebApp/emails/lingui.config.ts index 10bdfd4b9d..6e388b979c 100644 --- a/application/account/WebApp/emails/lingui.config.ts +++ b/application/account/WebApp/emails/lingui.config.ts @@ -2,7 +2,7 @@ import type { LinguiConfig } from "@lingui/conf"; import { formatter } from "@lingui/format-po"; -import i18nConfig from "../../../shared-webapp/infrastructure/translations/i18n.config.json"; +import i18nConfig from "../../../shared-webapp/infrastructure/translations/i18n.config"; const config: LinguiConfig = { locales: Object.keys(i18nConfig), diff --git a/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx b/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx index 9e4390e217..d088bf1a74 100644 --- a/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx +++ b/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx @@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; import { enhancedFetch } from "@repo/infrastructure/http/httpClient"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Button } from "@repo/ui/components/Button"; import { diff --git a/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx b/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx index afedb5f6d9..e1d88519ec 100644 --- a/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx +++ b/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx @@ -1,7 +1,7 @@ import { t } from "@lingui/core/macro"; import { trackInteraction } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; -import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { MoonStarIcon, SunMoonIcon } from "lucide-react"; import { useTheme } from "next-themes"; diff --git a/application/shared-webapp/emails/build/build.ts b/application/shared-webapp/emails/build/build.ts index a82adcd0e6..bb164b674f 100644 --- a/application/shared-webapp/emails/build/build.ts +++ b/application/shared-webapp/emails/build/build.ts @@ -10,7 +10,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createElement } from "react"; -import i18nConfig from "../../infrastructure/translations/i18n.config.json" with { type: "json" }; +import i18nConfig from "../../infrastructure/translations/i18n.config"; process.env.EMAIL_RENDER_MODE = "build"; diff --git a/application/shared-webapp/emails/lingui.factory.ts b/application/shared-webapp/emails/lingui.factory.ts index 893a9d0a13..5b290b9ba4 100644 --- a/application/shared-webapp/emails/lingui.factory.ts +++ b/application/shared-webapp/emails/lingui.factory.ts @@ -2,7 +2,7 @@ import type { LinguiConfig } from "@lingui/conf"; import { formatter } from "@lingui/format-po"; -import i18nConfig from "../infrastructure/translations/i18n.config.json"; +import i18nConfig from "../infrastructure/translations/i18n.config"; // Factory used by every system's emails/lingui.config.ts. Lingui resolves relative to the // directory containing the lingui.config file, which for emails is always /WebApp/emails. diff --git a/application/shared-webapp/infrastructure/translations/Translation.tsx b/application/shared-webapp/infrastructure/translations/Translation.tsx index 6dbe0b6873..b30c7555bd 100644 --- a/application/shared-webapp/infrastructure/translations/Translation.tsx +++ b/application/shared-webapp/infrastructure/translations/Translation.tsx @@ -4,7 +4,7 @@ import { i18n, type Messages } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; import { useEffect, useMemo, useState } from "react"; -import localeMap from "./i18n.config.json"; +import localeMap from "./i18n.config"; import { type TranslationContext, translationContext } from "./TranslationContext"; export type Locale = keyof typeof localeMap; diff --git a/application/shared-webapp/infrastructure/translations/createLinguiConfig.ts b/application/shared-webapp/infrastructure/translations/createLinguiConfig.ts index 2d2e500f6f..0c2002029d 100644 --- a/application/shared-webapp/infrastructure/translations/createLinguiConfig.ts +++ b/application/shared-webapp/infrastructure/translations/createLinguiConfig.ts @@ -2,7 +2,7 @@ import type { LinguiConfig } from "@lingui/conf"; import { formatter } from "@lingui/format-po"; -import i18nConfig from "./i18n.config.json"; +import i18nConfig from "./i18n.config"; export function createLinguiConfig(): LinguiConfig { return { diff --git a/application/shared-webapp/infrastructure/translations/i18n.config.json b/application/shared-webapp/infrastructure/translations/i18n.config.json deleted file mode 100644 index 81be396968..0000000000 --- a/application/shared-webapp/infrastructure/translations/i18n.config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "en-US": { - "locale": "en-US", - "label": "English", - "territory": "US", - "rtl": false - }, - "da-DK": { - "locale": "da-DK", - "label": "Dansk", - "territory": "DK", - "rtl": false - } -} diff --git a/application/shared-webapp/infrastructure/translations/i18n.config.ts b/application/shared-webapp/infrastructure/translations/i18n.config.ts new file mode 100644 index 0000000000..19362cacbe --- /dev/null +++ b/application/shared-webapp/infrastructure/translations/i18n.config.ts @@ -0,0 +1,8 @@ +// Single source of truth for the set of supported locales and their display metadata. +// `sourceLocale` is en-US; every locale listed here must have a `.po` catalog in each system. +const i18nConfig = { + "en-US": { locale: "en-US", label: "English", territory: "US", rtl: false }, + "da-DK": { locale: "da-DK", label: "Dansk", territory: "DK", rtl: false } +}; + +export default i18nConfig; From 19553d5d757b15c159c789c236ec0d44f85ce9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:04:30 +0200 Subject: [PATCH 03/24] Build shared-webapp libraries with rslib and consume them from dist --- .../account/BackOffice/rsbuild.config.ts | 4 - application/account/WebApp/rsbuild.config.ts | 4 - application/main/WebApp/rsbuild.config.ts | 4 - application/package-lock.json | 390 ++++++++++-------- application/package.json | 2 +- application/shared-webapp/build/package.json | 10 +- .../shared-webapp/build/rslib.config.ts | 22 + application/shared-webapp/build/tsconfig.json | 2 +- .../shared-webapp/infrastructure/package.json | 41 +- .../infrastructure/rslib.config.ts | 22 + .../createFederatedTranslation.ts | 11 +- .../infrastructure/tsconfig.json | 2 +- application/shared-webapp/ui/copyImages.js | 8 - application/shared-webapp/ui/package.json | 41 +- application/shared-webapp/ui/rslib.config.ts | 42 ++ application/shared-webapp/ui/tsconfig.json | 2 +- application/shared-webapp/utils/package.json | 10 +- .../shared-webapp/utils/rslib.config.ts | 21 + application/shared-webapp/utils/tsconfig.json | 2 +- 19 files changed, 386 insertions(+), 254 deletions(-) create mode 100644 application/shared-webapp/build/rslib.config.ts create mode 100644 application/shared-webapp/infrastructure/rslib.config.ts delete mode 100644 application/shared-webapp/ui/copyImages.js create mode 100644 application/shared-webapp/ui/rslib.config.ts create mode 100644 application/shared-webapp/utils/rslib.config.ts diff --git a/application/account/BackOffice/rsbuild.config.ts b/application/account/BackOffice/rsbuild.config.ts index 6d43539739..9c0473cddf 100644 --- a/application/account/BackOffice/rsbuild.config.ts +++ b/application/account/BackOffice/rsbuild.config.ts @@ -7,7 +7,6 @@ import { RunTimeEnvironmentPlugin } from "@repo/build/plugin/RunTimeEnvironmentP import { TailwindPlugin } from "@repo/build/plugin/TailwindPlugin"; import { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; -import { pluginSourceBuild } from "@rsbuild/plugin-source-build"; import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; @@ -38,9 +37,6 @@ export default defineConfig({ pluginReact(), pluginTypeCheck(), pluginSvgr(), - pluginSourceBuild({ - sourceField: "source" - }), FileSystemRouterPlugin(), RunTimeEnvironmentPlugin(customBuildEnv), LinguiPlugin(), diff --git a/application/account/WebApp/rsbuild.config.ts b/application/account/WebApp/rsbuild.config.ts index 0f4e495693..faa6cbadab 100644 --- a/application/account/WebApp/rsbuild.config.ts +++ b/application/account/WebApp/rsbuild.config.ts @@ -7,7 +7,6 @@ import { RunTimeEnvironmentPlugin } from "@repo/build/plugin/RunTimeEnvironmentP import { TailwindPlugin } from "@repo/build/plugin/TailwindPlugin"; import { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; -import { pluginSourceBuild } from "@rsbuild/plugin-source-build"; import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; @@ -45,9 +44,6 @@ export default defineConfig({ pluginReact(), pluginTypeCheck(), pluginSvgr(), - pluginSourceBuild({ - sourceField: "source" - }), FileSystemRouterPlugin(), RunTimeEnvironmentPlugin(customBuildEnv, { federationOnly: true }), LinguiPlugin(), diff --git a/application/main/WebApp/rsbuild.config.ts b/application/main/WebApp/rsbuild.config.ts index c103def91f..e979d0335c 100644 --- a/application/main/WebApp/rsbuild.config.ts +++ b/application/main/WebApp/rsbuild.config.ts @@ -8,7 +8,6 @@ import { RunTimeEnvironmentPlugin } from "@repo/build/plugin/RunTimeEnvironmentP import { TailwindPlugin } from "@repo/build/plugin/TailwindPlugin"; import { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; -import { pluginSourceBuild } from "@rsbuild/plugin-source-build"; import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; @@ -40,9 +39,6 @@ export default defineConfig({ pluginReact(), pluginTypeCheck(), pluginSvgr(), - pluginSourceBuild({ - sourceField: "source" - }), FileSystemRouterPlugin(), RunTimeEnvironmentPlugin(customBuildEnv), LinguiPlugin(), diff --git a/application/package-lock.json b/application/package-lock.json index 391ea2dc0c..469b9d5da4 100644 --- a/application/package-lock.json +++ b/application/package-lock.json @@ -52,9 +52,9 @@ "@playwright/test": "1.61.0", "@rsbuild/core": "2.0.15", "@rsbuild/plugin-react": "2.1.0", - "@rsbuild/plugin-source-build": "1.0.6", "@rsbuild/plugin-svgr": "2.0.4", "@rsbuild/plugin-type-check": "1.4.0", + "@rslib/core": "0.22.1", "@rspack/binding": "2.0.8", "@tailwindcss/postcss": "4.3.1", "@tanstack/router-devtools": "1.167.0", @@ -138,6 +138,180 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ast-grep/napi": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi/-/napi-0.37.0.tgz", + "integrity": "sha512-Hb4o6h1Pf6yRUAX07DR4JVY7dmQw+RVQMW5/m55GoiAT/VRoKCWBtIUPPOnqDVhbx1Cjfil9b6EDrgJsUAujEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ast-grep/napi-darwin-arm64": "0.37.0", + "@ast-grep/napi-darwin-x64": "0.37.0", + "@ast-grep/napi-linux-arm64-gnu": "0.37.0", + "@ast-grep/napi-linux-arm64-musl": "0.37.0", + "@ast-grep/napi-linux-x64-gnu": "0.37.0", + "@ast-grep/napi-linux-x64-musl": "0.37.0", + "@ast-grep/napi-win32-arm64-msvc": "0.37.0", + "@ast-grep/napi-win32-ia32-msvc": "0.37.0", + "@ast-grep/napi-win32-x64-msvc": "0.37.0" + } + }, + "node_modules/@ast-grep/napi-darwin-arm64": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-darwin-arm64/-/napi-darwin-arm64-0.37.0.tgz", + "integrity": "sha512-QAiIiaAbLvMEg/yBbyKn+p1gX2/FuaC0SMf7D7capm/oG4xGMzdeaQIcSosF4TCxxV+hIH4Bz9e4/u7w6Bnk3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-darwin-x64": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-darwin-x64/-/napi-darwin-x64-0.37.0.tgz", + "integrity": "sha512-zvcvdgekd4ySV3zUbUp8HF5nk5zqwiMXTuVzTUdl/w08O7JjM6XPOIVT+d2o/MqwM9rsXdzdergY5oY2RdhSPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-linux-arm64-gnu": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-arm64-gnu/-/napi-linux-arm64-gnu-0.37.0.tgz", + "integrity": "sha512-L7Sj0lXy8X+BqSMgr1LB8cCoWk0rericdeu+dC8/c8zpsav5Oo2IQKY1PmiZ7H8IHoFBbURLf8iklY9wsD+cyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-linux-arm64-musl": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-arm64-musl/-/napi-linux-arm64-musl-0.37.0.tgz", + "integrity": "sha512-LF9sAvYy6es/OdyJDO3RwkX3I82Vkfsng1sqUBcoWC1jVb1wX5YVzHtpQox9JrEhGl+bNp7FYxB4Qba9OdA5GA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-linux-x64-gnu": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-x64-gnu/-/napi-linux-x64-gnu-0.37.0.tgz", + "integrity": "sha512-TViz5/klqre6aSmJzswEIjApnGjJzstG/SE8VDWsrftMBMYt2PTu3MeluZVwzSqDao8doT/P+6U11dU05UOgxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-linux-x64-musl": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-linux-x64-musl/-/napi-linux-x64-musl-0.37.0.tgz", + "integrity": "sha512-/BcCH33S9E3ovOAEoxYngUNXgb+JLg991sdyiNP2bSoYd30a9RHrG7CYwW6fMgua3ijQ474eV6cq9yZO1bCpXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-win32-arm64-msvc": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-arm64-msvc/-/napi-win32-arm64-msvc-0.37.0.tgz", + "integrity": "sha512-TjQA4cFoIEW2bgjLkaL9yqT4XWuuLa5MCNd0VCDhGRDMNQ9+rhwi9eLOWRaap3xzT7g+nlbcEHL3AkVCD2+b3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-win32-ia32-msvc": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-ia32-msvc/-/napi-win32-ia32-msvc-0.37.0.tgz", + "integrity": "sha512-uNmVka8fJCdYsyOlF9aZqQMLTatEYBynjChVTzUfFMDfmZ0bihs/YTqJVbkSm8TZM7CUX82apvn50z/dX5iWRA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ast-grep/napi-win32-x64-msvc": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@ast-grep/napi-win32-x64-msvc/-/napi-win32-x64-msvc-0.37.0.tgz", + "integrity": "sha512-vCiFOT3hSCQuHHfZ933GAwnPzmL0G04JxQEsBRfqONywyT8bSdDc/ECpAfr3S9VcS4JZ9/F6tkePKW/Om2Dq2g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1927,44 +2101,6 @@ ], "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oxfmt/binding-android-arm-eabi": { "version": "0.56.0", "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.56.0.tgz", @@ -3489,26 +3625,6 @@ } } }, - "node_modules/@rsbuild/plugin-source-build": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@rsbuild/plugin-source-build/-/plugin-source-build-1.0.6.tgz", - "integrity": "sha512-F7m5qE/VzrUTq1ZsEmithuc+yxA52Bw4+6gkxprg+RpKqgBTLuv2sSQmpaJSYZrcLlkdCWLE4bwLY0zBT6MRAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-glob": "^3.3.3", - "json5": "^2.2.3", - "yaml": "^2.9.0" - }, - "peerDependencies": { - "@rsbuild/core": "^1.0.0 || ^2.0.0-0" - }, - "peerDependenciesMeta": { - "@rsbuild/core": { - "optional": true - } - } - }, "node_modules/@rsbuild/plugin-svgr": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@rsbuild/plugin-svgr/-/plugin-svgr-2.0.4.tgz", @@ -3557,6 +3673,35 @@ } } }, + "node_modules/@rslib/core": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@rslib/core/-/core-0.22.1.tgz", + "integrity": "sha512-RaqTITHFkpMDJG9fmD7Hu6FLE64hwctCo46asHOD2DipzQJWawg6K0pFGimTAyutYEZysIUfYgCwSYkbctDudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rsbuild/core": "~2.0.12", + "rsbuild-plugin-dts": "0.22.1" + }, + "bin": { + "rslib": "bin/rslib.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/@rspack/binding": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.0.8.tgz", @@ -5980,23 +6125,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -6013,16 +6141,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/file-selector": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", @@ -6909,16 +7027,6 @@ "tslib": "2" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromatch": { "version": "4.0.8", "dev": true, @@ -7738,27 +7846,6 @@ "node": ">=14" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -8642,17 +8729,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -8673,28 +8749,34 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/rsbuild-plugin-dts": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/rsbuild-plugin-dts/-/rsbuild-plugin-dts-0.22.1.tgz", + "integrity": "sha512-6vBfXgwK4j6GkjSRH13tuceBavc6qwSE5IVSoVCZ/wRuM0ZL7dIjdk+yGSWKJt5lNMfc4XwQoTErGIC9zhgztQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" + "license": "MIT", + "dependencies": { + "@ast-grep/napi": "0.37.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7", + "@rsbuild/core": "^1.0.0 || ^2.0.0-0", + "@typescript/native-preview": "^7.0.0-0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "@typescript/native-preview": { + "optional": true }, - { - "type": "consulting", - "url": "https://feross.org/support" + "typescript": { + "optional": true } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" } }, "node_modules/sax": { @@ -9979,22 +10061,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yaml-ast-parser": { "version": "0.0.43", "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", diff --git a/application/package.json b/application/package.json index 339b415bdb..21a3789879 100644 --- a/application/package.json +++ b/application/package.json @@ -58,9 +58,9 @@ "@playwright/test": "1.61.0", "@rsbuild/core": "2.0.15", "@rsbuild/plugin-react": "2.1.0", - "@rsbuild/plugin-source-build": "1.0.6", "@rsbuild/plugin-svgr": "2.0.4", "@rsbuild/plugin-type-check": "1.4.0", + "@rslib/core": "0.22.1", "@rspack/binding": "2.0.8", "@tailwindcss/postcss": "4.3.1", "@tanstack/router-devtools": "1.167.0", diff --git a/application/shared-webapp/build/package.json b/application/shared-webapp/build/package.json index aa24d4bc85..69ee298f2c 100644 --- a/application/shared-webapp/build/package.json +++ b/application/shared-webapp/build/package.json @@ -3,6 +3,10 @@ "version": "0.0.0", "private": true, "license": "MIT", + "files": [ + "dist" + ], + "type": "module", "exports": { ".": "./dist/index.js", "./*": "./dist/*.js", @@ -12,10 +16,10 @@ "./package.json": "./package.json" }, "scripts": { - "build": "rimraf ./dist && tsc -b . environment", + "build": "rslib build", "check": "oxfmt --check && oxlint", - "dev": "tsc -b . environment -w", - "dev:setup": "tsc -b . environment", + "dev": "rslib build --watch --no-clean", + "dev:setup": "rslib build", "format": "oxfmt && (oxlint --fix || true)", "lint": "oxlint --deny-warnings" }, diff --git a/application/shared-webapp/build/rslib.config.ts b/application/shared-webapp/build/rslib.config.ts new file mode 100644 index 0000000000..c29ac89afc --- /dev/null +++ b/application/shared-webapp/build/rslib.config.ts @@ -0,0 +1,22 @@ +import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { + entry: { + index: ["./**/*.tsx", "./**/*.ts", "./**/*.svg", "./**/*.css", "!rslib.config.ts", "!node_modules/**", "!dist/**"] + } + }, + lib: [ + { + bundle: false, + dts: true, + format: "esm" + } + ], + output: { + target: "web" + }, + plugins: [pluginReact(), pluginTypeCheck()] +}); diff --git a/application/shared-webapp/build/tsconfig.json b/application/shared-webapp/build/tsconfig.json index 800ce20eeb..2d407e268f 100644 --- a/application/shared-webapp/build/tsconfig.json +++ b/application/shared-webapp/build/tsconfig.json @@ -6,5 +6,5 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["node_modules", "dist", "environment"] + "exclude": ["node_modules", "dist", "environment", "rslib.config.ts"] } diff --git a/application/shared-webapp/infrastructure/package.json b/application/shared-webapp/infrastructure/package.json index ce666af1d0..4b6bfe2e02 100644 --- a/application/shared-webapp/infrastructure/package.json +++ b/application/shared-webapp/infrastructure/package.json @@ -3,46 +3,23 @@ "version": "0.0.0", "private": true, "license": "MIT", - "source": "./index.ts", - "main": "./dist/index.js", + "files": [ + "dist" + ], + "type": "module", "exports": { - ".": { - "types": "./dist/index.d.ts", - "source": "./index.ts", - "default": "./dist/index.js" - }, - "./auth/*": { - "types": "./dist/auth/*.d.ts", - "source": "./auth/*", - "default": "./dist/auth/*" - }, - "./http/*": { - "types": "./dist/http/*.d.ts", - "source": "./http/*", - "default": "./dist/http/*" - }, - "./applicationInsights/*": { - "types": "./dist/applicationInsights/*.d.ts", - "source": "./applicationInsights/*", - "default": "./dist/applicationInsights/*" - }, - "./translations/*": { - "types": "./dist/translations/*.d.ts", - "source": "./translations/*", - "default": "./dist/translations/*" - }, "./*": { "types": "./dist/*.d.ts", - "source": "./*", - "default": "./dist/*" + "import": "./dist/*.js", + "require": "./dist/*.js" }, "./package.json": "./package.json" }, "scripts": { - "build": "rimraf ./dist && tsc -p .", + "build": "rslib build", "check": "oxfmt --check && oxlint", - "dev": "tsc -p . -w", - "dev:setup": "tsc -p .", + "dev": "rslib build --watch --no-clean", + "dev:setup": "rslib build", "format": "oxfmt && (oxlint --fix || true)", "lint": "oxlint --deny-warnings" }, diff --git a/application/shared-webapp/infrastructure/rslib.config.ts b/application/shared-webapp/infrastructure/rslib.config.ts new file mode 100644 index 0000000000..00404f5f17 --- /dev/null +++ b/application/shared-webapp/infrastructure/rslib.config.ts @@ -0,0 +1,22 @@ +import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { + entry: { + index: ["./**/*.tsx", "./**/*.ts", "./**/*.json", "!rslib.config.ts", "!node_modules/**", "!dist/**"] + } + }, + lib: [ + { + bundle: false, + dts: true, + format: "esm" + } + ], + output: { + target: "web" + }, + plugins: [pluginReact(), pluginTypeCheck()] +}); diff --git a/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts b/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts index c4e13a7d95..75ebb302d8 100644 --- a/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts +++ b/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts @@ -74,10 +74,19 @@ async function loadRemoteTranslations(remoteName: string, locale: Locale): Promi /** * Load translations from `@repo/ui` (the shared component library catalog). * These are merged underneath the host SPA's own messages so the host can override. + * + * `@repo/ui` is consumed as a built package (its `exports` map subpaths into `dist`), so a + * templated dynamic import cannot be resolved into a scannable directory by the bundler. Each + * locale is therefore imported explicitly; keep this map in sync with `i18n.config.ts`. */ +const sharedCatalogLoaders: Record Promise<{ messages?: Messages }>> = { + "en-US": () => import("@repo/ui/translations/locale/en-US"), + "da-DK": () => import("@repo/ui/translations/locale/da-DK") +}; + async function loadSharedTranslations(locale: Locale): Promise { try { - const module = await import(`@repo/ui/translations/locale/${locale}.ts`); + const module = await sharedCatalogLoaders[locale]?.(); return module?.messages ?? null; } catch { return null; diff --git a/application/shared-webapp/infrastructure/tsconfig.json b/application/shared-webapp/infrastructure/tsconfig.json index 241384c924..14ddddee08 100644 --- a/application/shared-webapp/infrastructure/tsconfig.json +++ b/application/shared-webapp/infrastructure/tsconfig.json @@ -6,5 +6,5 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "rslib.config.ts"] } diff --git a/application/shared-webapp/ui/copyImages.js b/application/shared-webapp/ui/copyImages.js deleted file mode 100644 index e8534f99f8..0000000000 --- a/application/shared-webapp/ui/copyImages.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * This script copies the images from the images folder to the dist folder. - * We are waiting for rslib to provide a better solution for this. - */ -const fs = require("node:fs"); -const path = require("node:path"); - -fs.cpSync(path.resolve(__dirname, "images"), path.resolve(__dirname, "dist", "images"), { recursive: true }); diff --git a/application/shared-webapp/ui/package.json b/application/shared-webapp/ui/package.json index 9a56a20ba0..05259e04ad 100644 --- a/application/shared-webapp/ui/package.json +++ b/application/shared-webapp/ui/package.json @@ -3,44 +3,29 @@ "version": "0.0.0", "private": true, "license": "MIT", - "source": "./index.ts", - "main": "./dist/index.js", + "files": [ + "dist" + ], + "type": "module", "exports": { - ".": { - "types": "./dist/index.d.ts", - "source": "./index.ts", - "default": "./dist/index.js" - }, - "./components/*": { - "types": "./dist/components/*.d.ts", - "source": "./components/*", - "default": "./dist/components/*" - }, - "./forms/*": { - "types": "./dist/forms/*.d.ts", - "source": "./forms/*", - "default": "./dist/forms/*" - }, - "./theme/*": { - "types": "./dist/theme/*.d.ts", - "source": "./theme/*", - "default": "./dist/theme/*" - }, + "./*.css": "./dist/*.css", + "./*.svg": "./dist/*.svg", + "./*.webp": "./dist/*.webp", + "./*.png": "./dist/*.png", "./*": { "types": "./dist/*.d.ts", - "source": "./*", - "default": "./dist/*" + "import": "./dist/*.js", + "require": "./dist/*.js" }, "./theme.css": "./theme.css", "./tailwind.css": "./tailwind.css", "./package.json": "./package.json" }, "scripts": { - "build": "rimraf ./dist && npm run copyImages && tsc -p .", + "build": "rslib build", "check": "oxfmt --check && oxlint", - "copyImages": "node ./copyImages.js", - "dev": "tsc -p . -w", - "dev:setup": "rimraf ./dist && npm run copyImages && tsc -p .", + "dev": "rslib build --watch --no-clean", + "dev:setup": "rslib build", "format": "oxfmt && (oxlint --fix || true)", "lint": "oxlint --deny-warnings", "update-translations": "lingui extract --clean && lingui compile --typescript" diff --git a/application/shared-webapp/ui/rslib.config.ts b/application/shared-webapp/ui/rslib.config.ts new file mode 100644 index 0000000000..8101ece015 --- /dev/null +++ b/application/shared-webapp/ui/rslib.config.ts @@ -0,0 +1,42 @@ +import { LinguiPlugin } from "@repo/build/plugin/LinguiPlugin"; +import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { + entry: { + index: [ + "./**/*.tsx", + "./**/*.ts", + "./**/*.svg", + "./**/*.css", + "./**/*.png", + "./**/*.webp", + "!rslib.config.ts", + "!node_modules/**", + "!dist/**" + ] + } + }, + lib: [ + { + bundle: false, + dts: true, + format: "esm" + } + ], + output: { + target: "web", + // Emit raster image assets (png/webp logos + hero images) unhashed into `dist/images` so they land + // exactly where the package `exports` (`./*.png`, `./*.webp`) and component imports resolve, letting + // rslib own image emission instead of a hand-rolled copy step. + distPath: { + image: "images" + }, + filename: { + image: "[name][ext]" + } + }, + plugins: [pluginReact(), pluginTypeCheck(), LinguiPlugin()] +}); diff --git a/application/shared-webapp/ui/tsconfig.json b/application/shared-webapp/ui/tsconfig.json index 07df91894e..2f4a377c30 100644 --- a/application/shared-webapp/ui/tsconfig.json +++ b/application/shared-webapp/ui/tsconfig.json @@ -9,5 +9,5 @@ "@/*": ["./*"] } }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "rslib.config.ts"] } diff --git a/application/shared-webapp/utils/package.json b/application/shared-webapp/utils/package.json index 553a4d87e9..f44f3533a4 100644 --- a/application/shared-webapp/utils/package.json +++ b/application/shared-webapp/utils/package.json @@ -3,16 +3,20 @@ "version": "0.0.0", "private": true, "license": "MIT", + "files": [ + "dist" + ], + "type": "module", "exports": { ".": "./dist/index.js", "./*": "./dist/*.js", "./package.json": "./package.json" }, "scripts": { - "build": "rimraf ./dist && tsc -p .", + "build": "rslib build", "check": "oxfmt --check && oxlint", - "dev": "tsc -p . -w", - "dev:setup": "tsc -p .", + "dev": "rslib build --watch --no-clean", + "dev:setup": "rslib build", "format": "oxfmt && (oxlint --fix || true)", "lint": "oxlint --deny-warnings" }, diff --git a/application/shared-webapp/utils/rslib.config.ts b/application/shared-webapp/utils/rslib.config.ts new file mode 100644 index 0000000000..e72b311825 --- /dev/null +++ b/application/shared-webapp/utils/rslib.config.ts @@ -0,0 +1,21 @@ +import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { + entry: { + index: ["./**/*.tsx", "./**/*.ts", "!rslib.config.ts", "!node_modules/**", "!dist/**"] + } + }, + lib: [ + { + bundle: false, + dts: true, + format: "esm" + } + ], + output: { + target: "web" + }, + plugins: [pluginTypeCheck()] +}); diff --git a/application/shared-webapp/utils/tsconfig.json b/application/shared-webapp/utils/tsconfig.json index dd796a94b6..a840efb26b 100644 --- a/application/shared-webapp/utils/tsconfig.json +++ b/application/shared-webapp/utils/tsconfig.json @@ -6,5 +6,5 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "rslib.config.ts"] } From 76e0c501bff5386028e0ce935eeef389d69c2360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:04:38 +0200 Subject: [PATCH 04/24] Share @lingui/core and @lingui/react as module-federation singletons --- .../build/plugin/ModuleFederationPlugin.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts index 30a8372f69..e051689f52 100644 --- a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts +++ b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts @@ -48,6 +48,17 @@ export function ModuleFederationPlugin({ "react-dom": { singleton: true, requiredVersion: dependencies["react-dom"] + }, + // Lingui's `i18n` is a module-level singleton exported from @lingui/core. It must be a + // single instance across every remote so the merged catalog activated by one system is + // the same object every other system's ``/`useLingui` reads from. + "@lingui/core": { + singleton: true, + requiredVersion: dependencies["@lingui/core"] + }, + "@lingui/react": { + singleton: true, + requiredVersion: dependencies["@lingui/react"] } } } From 7b7e184becb64a66492840f882af9d78a8e426ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:07:14 +0200 Subject: [PATCH 05/24] Decouple federated translations via self-registration --- application/account/BackOffice/main.tsx | 4 +- .../WebApp/federated-modules/AccountApp.tsx | 6 +- .../federated-modules/banners/Banners.tsx | 6 +- .../common/AuthSyncModal.tsx | 6 +- .../errorPages/AccessDeniedPage.tsx | 6 +- .../errorPages/ErrorPage.tsx | 6 +- .../errorPages/NotFoundPage.tsx | 6 +- .../federated-modules/public/PublicFooter.tsx | 6 +- .../public/PublicNavigation.tsx | 5 +- .../federated-modules/sideMenu/MobileMenu.tsx | 6 +- .../subscription/SuspendedPage.tsx | 6 +- .../subscription/TenantStateGuard.tsx | 5 +- .../federated-modules/userMenu/UserMenu.tsx | 5 +- .../translations/withAccountTranslations.tsx | 9 + application/main/WebApp/bootstrap.tsx | 4 +- .../translations/Translation.tsx | 287 ++++++++++++------ .../createFederatedTranslation.ts | 119 -------- 17 files changed, 258 insertions(+), 234 deletions(-) create mode 100644 application/account/WebApp/shared/translations/withAccountTranslations.tsx delete mode 100644 application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts diff --git a/application/account/BackOffice/main.tsx b/application/account/BackOffice/main.tsx index 3e2df29c71..8e66caacbb 100644 --- a/application/account/BackOffice/main.tsx +++ b/application/account/BackOffice/main.tsx @@ -2,7 +2,7 @@ import "@repo/ui/theme.css"; import "@repo/ui/tailwind.css"; import { ApplicationInsightsProvider } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; import { setupGlobalErrorHandlers } from "@repo/infrastructure/http/globalErrorHandlers"; -import { createFederatedTranslation } from "@repo/infrastructure/translations/createFederatedTranslation"; +import { Translation } from "@repo/infrastructure/translations/Translation"; import { Toaster } from "@repo/ui/components/Sonner"; import { RouterProvider } from "@tanstack/react-router"; import React from "react"; @@ -10,7 +10,7 @@ import reactDom from "react-dom/client"; import { router } from "@/shared/lib/router/router"; -const { TranslationProvider } = await createFederatedTranslation( +const { TranslationProvider } = await Translation.create( (locale) => import(`@/shared/translations/locale/${locale}.ts`) ); diff --git a/application/account/WebApp/federated-modules/AccountApp.tsx b/application/account/WebApp/federated-modules/AccountApp.tsx index 055854f3ed..7ecd2c755d 100644 --- a/application/account/WebApp/federated-modules/AccountApp.tsx +++ b/application/account/WebApp/federated-modules/AccountApp.tsx @@ -5,6 +5,8 @@ import { shouldBlockNavigation } from "@repo/ui/hooks/federatedNavigationGuard"; import { createRouter, type NavigateOptions, RouterProvider } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import { UnsavedChangesDialog } from "../shared/components/UnsavedChangesDialog"; import { MainNavigationContext } from "../shared/hooks/useMainNavigation"; import { routeTree } from "../shared/lib/router/routeTree.generated"; @@ -14,7 +16,7 @@ export interface AccountAppProps { onNavigateToMain: (path: string) => void; } -export default function AccountApp({ initialPath, onNavigateToMain }: Readonly) { +function AccountApp({ initialPath, onNavigateToMain }: Readonly) { // Store initialPath in a ref to prevent router recreation when main's router re-renders // due to URL changes from window.history.pushState() calls within this component const initialPathRef = useRef(initialPath); @@ -88,3 +90,5 @@ export default function AccountApp({ initialPath, onNavigateToMain }: Readonly ); } + +export default withAccountTranslations(AccountApp); diff --git a/application/account/WebApp/federated-modules/banners/Banners.tsx b/application/account/WebApp/federated-modules/banners/Banners.tsx index 5a9775028d..1baaa06471 100644 --- a/application/account/WebApp/federated-modules/banners/Banners.tsx +++ b/application/account/WebApp/federated-modules/banners/Banners.tsx @@ -1,12 +1,14 @@ import { useState } from "react"; import { createPortal } from "react-dom"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import ExpiringCardBanner from "./ExpiringCardBanner"; import InvitationBanner from "./InvitationBanner"; import PaymentFailedBanner from "./PaymentFailedBanner"; import "@repo/ui/tailwind.css"; -export default function Banners() { +function Banners() { const [target] = useState(() => document.getElementById("banner-root")); if (!target) { @@ -22,3 +24,5 @@ export default function Banners() { target ); } + +export default withAccountTranslations(Banners); diff --git a/application/account/WebApp/federated-modules/common/AuthSyncModal.tsx b/application/account/WebApp/federated-modules/common/AuthSyncModal.tsx index 8447297031..1ba5ad9342 100644 --- a/application/account/WebApp/federated-modules/common/AuthSyncModal.tsx +++ b/application/account/WebApp/federated-modules/common/AuthSyncModal.tsx @@ -3,6 +3,8 @@ import { Trans } from "@lingui/react/macro"; import { Button } from "@repo/ui/components/Button"; import { Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@repo/ui/components/Dialog"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + export type AuthSyncModalType = "tenant-switch" | "logged-in" | "logged-out"; export interface AuthSyncModalProps { @@ -12,7 +14,7 @@ export interface AuthSyncModalProps { onPrimaryAction: () => void; } -export default function AuthSyncModal({ isOpen, type, newTenantName, onPrimaryAction }: AuthSyncModalProps) { +function AuthSyncModal({ isOpen, type, newTenantName, onPrimaryAction }: AuthSyncModalProps) { const getModalContent = () => { switch (type) { case "tenant-switch": @@ -97,3 +99,5 @@ export default function AuthSyncModal({ isOpen, type, newTenantName, onPrimaryAc ); } + +export default withAccountTranslations(AuthSyncModal); diff --git a/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx b/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx index e41e4bb813..965cb48a46 100644 --- a/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx +++ b/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx @@ -11,6 +11,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { Home, LogOut, ShieldX } from "lucide-react"; import { useContext, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import LocaleSwitcher from "../common/LocaleSwitcher"; import SupportButton from "../common/SupportButton"; import ThemeModeSelector from "../common/ThemeModeSelector"; @@ -85,7 +87,7 @@ function AccessDeniedNavigation() { ); } -export default function AccessDeniedPage() { +function AccessDeniedPage() { return (
@@ -123,3 +125,5 @@ export default function AccessDeniedPage() {
); } + +export default withAccountTranslations(AccessDeniedPage); diff --git a/application/account/WebApp/federated-modules/errorPages/ErrorPage.tsx b/application/account/WebApp/federated-modules/errorPages/ErrorPage.tsx index e0aa023872..875ee1c5d8 100644 --- a/application/account/WebApp/federated-modules/errorPages/ErrorPage.tsx +++ b/application/account/WebApp/federated-modules/errorPages/ErrorPage.tsx @@ -15,6 +15,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { AlertTriangle, Home, LogOut, RefreshCw } from "lucide-react"; import { useContext, useEffect, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import LocaleSwitcher from "../common/LocaleSwitcher"; import SupportButton from "../common/SupportButton"; import ThemeModeSelector from "../common/ThemeModeSelector"; @@ -91,7 +93,7 @@ function ErrorNavigation() { ); } -export default function ErrorPage(props: Readonly) { +function ErrorPage(props: Readonly) { // Handle not found errors with dedicated page if (isNotFoundError(props.error)) { return ; @@ -192,3 +194,5 @@ function GeneralErrorPage({ error }: Readonly) { ); } + +export default withAccountTranslations(ErrorPage); diff --git a/application/account/WebApp/federated-modules/errorPages/NotFoundPage.tsx b/application/account/WebApp/federated-modules/errorPages/NotFoundPage.tsx index 4b89b2b094..4fff00c11e 100644 --- a/application/account/WebApp/federated-modules/errorPages/NotFoundPage.tsx +++ b/application/account/WebApp/federated-modules/errorPages/NotFoundPage.tsx @@ -11,6 +11,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { FileQuestion, Home, LogOut } from "lucide-react"; import { useContext, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import LocaleSwitcher from "../common/LocaleSwitcher"; import SupportButton from "../common/SupportButton"; import ThemeModeSelector from "../common/ThemeModeSelector"; @@ -85,7 +87,7 @@ function NotFoundNavigation() { ); } -export default function NotFoundPage() { +function NotFoundPage() { return (
@@ -123,3 +125,5 @@ export default function NotFoundPage() {
); } + +export default withAccountTranslations(NotFoundPage); diff --git a/application/account/WebApp/federated-modules/public/PublicFooter.tsx b/application/account/WebApp/federated-modules/public/PublicFooter.tsx index f1a6700e91..72467540b9 100644 --- a/application/account/WebApp/federated-modules/public/PublicFooter.tsx +++ b/application/account/WebApp/federated-modules/public/PublicFooter.tsx @@ -9,6 +9,8 @@ import { Logo } from "@repo/ui/components/Logo"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; import { MailIcon } from "lucide-react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + // Brand icons removed from lucide-react v1 for trademark reasons; inlined here as SVGs. function GithubIcon({ className }: { readonly className?: string }) { return ( @@ -81,7 +83,7 @@ function SocialLinkButton({ href, ariaLabel, tooltip, children }: SocialLinkButt ); } -export default function PublicFooter() { +function PublicFooter() { const currentYear = new Date().getFullYear(); const { i18n } = useLingui(); const tagline = webTaglines[i18n.locale]; @@ -177,3 +179,5 @@ export default function PublicFooter() { ); } + +export default withAccountTranslations(PublicFooter); diff --git a/application/account/WebApp/federated-modules/public/PublicNavigation.tsx b/application/account/WebApp/federated-modules/public/PublicNavigation.tsx index 8c61d1e5c9..917b83d25c 100644 --- a/application/account/WebApp/federated-modules/public/PublicNavigation.tsx +++ b/application/account/WebApp/federated-modules/public/PublicNavigation.tsx @@ -10,8 +10,9 @@ import { Suspense } from "react"; import LocaleSwitcher from "@/federated-modules/common/LocaleSwitcher"; import ThemeModeSelector from "@/federated-modules/common/ThemeModeSelector"; import UserMenu from "@/federated-modules/userMenu/UserMenu"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; -export default function PublicNavigation() { +function PublicNavigation() { const isAuthenticated = useIsAuthenticated(); return ( @@ -46,3 +47,5 @@ export default function PublicNavigation() { ); } + +export default withAccountTranslations(PublicNavigation); diff --git a/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx b/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx index e592017d7a..40ca047de7 100644 --- a/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx +++ b/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx @@ -20,6 +20,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { LayoutDashboardIcon, MessageCircleQuestion } from "lucide-react"; import { useContext, useEffect, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import { SupportDialog } from "../common/SupportDialog"; import { SwitchingAccountLoader } from "../common/SwitchingAccountLoader"; import { fetchTenants, switchTenantApi, type TenantInfo } from "../common/tenantUtils"; @@ -110,7 +112,7 @@ export interface MobileMenuProps { // Rich mobile navigation surface rendered inside the Sidebar's mobile Sheet. Shows tenant info, // user actions, tenant switcher, navigation links, and a support button. Federated so both the // Main and Account apps can reuse it inside their mobile sidebars. -export default function MobileMenu({ onNavigate }: Readonly) { +function MobileMenu({ onNavigate }: Readonly) { const userInfo = useUserInfo(); const overlayCtx = useContext(overlayContext); const [tenants, setTenants] = useState([]); @@ -228,3 +230,5 @@ export default function MobileMenu({ onNavigate }: Readonly) { ); } + +export default withAccountTranslations(MobileMenu); diff --git a/application/account/WebApp/federated-modules/subscription/SuspendedPage.tsx b/application/account/WebApp/federated-modules/subscription/SuspendedPage.tsx index b18f6949e8..a163b40740 100644 --- a/application/account/WebApp/federated-modules/subscription/SuspendedPage.tsx +++ b/application/account/WebApp/federated-modules/subscription/SuspendedPage.tsx @@ -10,6 +10,8 @@ import { Logo } from "@repo/ui/components/Logo"; import { AlertTriangleIcon, LogOut } from "lucide-react"; import { useContext, useState } from "react"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + import LocaleSwitcher from "../common/LocaleSwitcher"; import SupportButton from "../common/SupportButton"; import ThemeModeSelector from "../common/ThemeModeSelector"; @@ -75,7 +77,7 @@ function SuspendedNavigation() { ); } -export default function SuspendedPage() { +function SuspendedPage() { const userInfo = useUserInfo(); const isOwner = userInfo?.role === "Owner"; @@ -121,3 +123,5 @@ export default function SuspendedPage() { ); } + +export default withAccountTranslations(SuspendedPage); diff --git a/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx b/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx index fe0141b950..336c1617dc 100644 --- a/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx +++ b/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { api, TenantState } from "@/shared/lib/api/client"; +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; import SuspendedPage from "./SuspendedPage"; @@ -9,7 +10,7 @@ interface TenantStateGuardProps { pathname: string; } -export default function TenantStateGuard({ children, pathname }: Readonly) { +function TenantStateGuard({ children, pathname }: Readonly) { const { data: tenant } = api.useQuery("get", "/api/account/tenants/current"); const isBillingPage = pathname.startsWith("/account/billing"); @@ -20,3 +21,5 @@ export default function TenantStateGuard({ children, pathname }: Readonly) { +function UserMenu({ isCollapsed: isCollapsedProp }: Readonly) { const userInfo = useUserInfo(); const isCollapsedContext = useContext(collapsedContext); const isCollapsed = isCollapsedProp ?? isCollapsedContext; @@ -169,3 +170,5 @@ export default function UserMenu({ isCollapsed: isCollapsedProp }: Readonly ); } + +export default withAccountTranslations(UserMenu); diff --git a/application/account/WebApp/shared/translations/withAccountTranslations.tsx b/application/account/WebApp/shared/translations/withAccountTranslations.tsx new file mode 100644 index 0000000000..42d18516ce --- /dev/null +++ b/application/account/WebApp/shared/translations/withAccountTranslations.tsx @@ -0,0 +1,9 @@ +import { type Locale, withSystemTranslations } from "@repo/infrastructure/translations/Translation"; + +// Account's federated modules render inside other systems (e.g. main), where account has no bootstrap of +// its own. Wrapping each exposed module self-registers account's catalog into the shared translation +// dictionary and suspends until it is merged, so account strings are translated on first paint. +export const withAccountTranslations = withSystemTranslations( + "account", + (locale: Locale) => import(`@/shared/translations/locale/${locale}.ts`) +); diff --git a/application/main/WebApp/bootstrap.tsx b/application/main/WebApp/bootstrap.tsx index 3e2df29c71..8e66caacbb 100644 --- a/application/main/WebApp/bootstrap.tsx +++ b/application/main/WebApp/bootstrap.tsx @@ -2,7 +2,7 @@ import "@repo/ui/theme.css"; import "@repo/ui/tailwind.css"; import { ApplicationInsightsProvider } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; import { setupGlobalErrorHandlers } from "@repo/infrastructure/http/globalErrorHandlers"; -import { createFederatedTranslation } from "@repo/infrastructure/translations/createFederatedTranslation"; +import { Translation } from "@repo/infrastructure/translations/Translation"; import { Toaster } from "@repo/ui/components/Sonner"; import { RouterProvider } from "@tanstack/react-router"; import React from "react"; @@ -10,7 +10,7 @@ import reactDom from "react-dom/client"; import { router } from "@/shared/lib/router/router"; -const { TranslationProvider } = await createFederatedTranslation( +const { TranslationProvider } = await Translation.create( (locale) => import(`@/shared/translations/locale/${locale}.ts`) ); diff --git a/application/shared-webapp/infrastructure/translations/Translation.tsx b/application/shared-webapp/infrastructure/translations/Translation.tsx index b30c7555bd..7a02097a0a 100644 --- a/application/shared-webapp/infrastructure/translations/Translation.tsx +++ b/application/shared-webapp/infrastructure/translations/Translation.tsx @@ -1,11 +1,12 @@ import type React from "react"; import { i18n, type Messages } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { useEffect, useMemo, useState } from "react"; +import { I18nProvider, useLingui } from "@lingui/react"; +import { type ComponentType, Suspense, useEffect, useMemo, useState } from "react"; +import { preferredLocaleKey } from "./constants"; import localeMap from "./i18n.config"; -import { type TranslationContext, translationContext } from "./TranslationContext"; +import { type TranslationContext as TranslationContextValue, translationContext } from "./TranslationContext"; export type Locale = keyof typeof localeMap; @@ -26,128 +27,177 @@ export type LocalLoaderFunction = (locale: Locale) => Promise; const TranslationContextProvider = translationContext.Provider; -export class Translation { - private readonly _messageCache = new Map(); - private readonly _defaultLocale = document.documentElement.lang as Locale; +export const locales = Object.keys(localeMap) as Locale[]; - /** - * Prefer using `TranslationConfig.create` instead of this constructor - */ - private readonly localeLoader: LocalLoaderFunction; +export function getLocaleInfo(locale: Locale): LocaleInfo { + return localeMap[locale]; +} - constructor(localeLoader: LocalLoaderFunction) { - this.localeLoader = localeLoader; - } +export function isLocale(value: string): value is Locale { + return value in localeMap; +} - private readonly _locales: Locale[] = Object.keys(localeMap) as Locale[]; +/** + * The single point of coordination for translations across all self-contained systems. + * + * Only the global Lingui `i18n` object (shared as a module-federation singleton) and `globalThis` + * are guaranteed to be single instances across remotes -- `@repo/*` packages are compiled per-remote, + * so a module-level store would be duplicated. Every system registers a loader for its own catalog and + * all catalogs merge into the one shared `i18n` dictionary. + */ +type TranslationStore = { + activeLocale: Locale | null; + loaders: Map; + merged: Map; + systemLoaded: Map>; + loadInflight: Map>; + activateInflight: Map>; +}; - /** - * Get the list of available locales - */ - public get locales(): Locale[] { - return this._locales; - } +const store: TranslationStore = ((globalThis as Record).__appTranslation ??= { + activeLocale: null, + loaders: new Map(), + merged: new Map(), + systemLoaded: new Map(), + loadInflight: new Map(), + activateInflight: new Map() +} satisfies TranslationStore) as TranslationStore; + +// `@repo/ui` is consumed as a built package (its `exports` map subpaths into `dist`), so a templated +// dynamic import cannot be resolved into a scannable directory by the bundler. Each locale is imported +// explicitly; keep this map in sync with `i18n.config.ts`. +const sharedUiSystemId = "shared-ui"; +const sharedUiCatalogs: Record Promise<{ messages?: Messages }>> = { + "en-US": () => import("@repo/ui/translations/locale/en-US"), + "da-DK": () => import("@repo/ui/translations/locale/da-DK") +}; +const sharedUiLoader: LocalLoaderFunction = async (locale) => { + const module = await sharedUiCatalogs[locale]?.(); + return { messages: module?.messages ?? {} }; +}; - /** - * Create a new TranslationConfig instance and load the initial locale - */ - public static async create(localeLoader: LocalLoaderFunction): Promise { - const config = new Translation(localeLoader); - await config.dynamicActivate(); - return config; +export function registerCatalog(systemId: string, loader: LocalLoaderFunction): void { + if (!store.loaders.has(systemId)) { + store.loaders.set(systemId, loader); } +} - /** - * Load and activate the given locale - */ - public async dynamicActivate(newLocale?: string | undefined) { - const locale = this.getLocale(newLocale); - const { messages } = await this.loadCatalog(locale); - i18n.loadAndActivate({ locale: locale as string, messages }); +function markLoaded(systemId: string, locale: Locale): void { + let set = store.systemLoaded.get(systemId); + if (!set) { + set = new Set(); + store.systemLoaded.set(systemId, set); } + set.add(locale); +} - /** - * Get the locale info for the given locale - */ - public getLocaleInfo(locale: Locale): LocaleInfo { - return localeMap[locale]; +function isLoaded(systemId: string, locale: Locale): boolean { + return store.systemLoaded.get(systemId)?.has(locale) ?? false; +} + +/** Load a system's catalog for a locale into the merged dictionary (no activation). Idempotent. */ +function loadSystemLocale(systemId: string, loader: LocalLoaderFunction, locale: Locale): Promise { + if (isLoaded(systemId, locale)) { + return Promise.resolve(); } + const key = `${systemId}:${locale}`; + const existing = store.loadInflight.get(key); + if (existing) { + return existing; + } + const promise = loader(locale) + .then(({ messages }) => { + // Later merges win; the shared-ui loader is registered first so it stays lowest precedence, the host + // next, and federated remotes register on mount so they layer on top -- matching the previous + // "shared < own < remote" precedence. + store.merged.set(locale, { ...store.merged.get(locale), ...messages }); + }) + .catch((error) => { + // A single system's catalog failing to load must not reject: that would crash the Suspense boundary + // in `SystemTranslationGate`, or abort a locale switch (`loadAllAndActivate`) for every other system. + // Degrade to untranslated for just this system instead. + console.error(`Failed to load translations for "${systemId}" (${locale})`, error); + }) + .finally(() => { + // Mark loaded on success and on failure alike, so a persistent load error degrades gracefully to + // untranslated rather than re-suspending forever. + markLoaded(systemId, locale); + }); + store.loadInflight.set(key, promise); + return promise; +} - /** - * This component should be used as a wrapper around the application to provide - * the translation context to the rest of the application - * - * @param children The children to render - */ - public TranslationProvider = ({ children }: { children: React.ReactNode }) => { - return {children}; - }; +/** Load every registered system's catalog for the locale, then activate the merged dictionary once. */ +async function loadAllAndActivate(locale: Locale): Promise { + await Promise.all([...store.loaders].map(([systemId, loader]) => loadSystemLocale(systemId, loader, locale))); + store.activeLocale = locale; + i18n.loadAndActivate({ locale, messages: store.merged.get(locale) ?? {} }); +} - /** - * Get the locale for the application - */ - private getLocale(locale?: string): Locale { - if (locale && this.isLocale(locale)) { - return locale; - } - if (import.meta.env.LOCALE && this.isLocale(import.meta.env.LOCALE)) { - return import.meta.env.LOCALE; - } - return this._defaultLocale; +/** Merge a single system's catalog into the currently active locale (used when it mounts late). */ +function ensureSystemActive(systemId: string, loader: LocalLoaderFunction, locale: Locale): Promise { + const key = `${systemId}:${locale}`; + const existing = store.activateInflight.get(key); + if (existing) { + return existing; } - - /** - * Load the catalog for the given locale - */ - private async loadCatalog(locale: Locale): Promise { - const existingLocaleFile = this._messageCache.get(locale); - if (existingLocaleFile) { - return existingLocaleFile; + const promise = loadSystemLocale(systemId, loader, locale).then(() => { + if (store.activeLocale === locale) { + i18n.loadAndActivate({ locale, messages: store.merged.get(locale) ?? {} }); } + }); + store.activateInflight.set(key, promise); + return promise; +} - const messageFile = await this.localeLoader(locale); - this._messageCache.set(locale, messageFile); - - return messageFile; +/** + * Resolve the initial locale. Authenticated users follow the server (``, set from the JWT); + * anonymous users have their stored choice re-applied by `useInitializeLocale` after mount. + */ +function resolveInitialLocale(): Locale { + const serverLocale = document.documentElement.lang; + if (isLocale(serverLocale)) { + return serverLocale; + } + if (import.meta.env.LOCALE && isLocale(import.meta.env.LOCALE)) { + return import.meta.env.LOCALE; } + return locales[0]; +} - /** - * Assert that the given string is a valid locale - */ - private isLocale(locale: string): locale is Locale { - return locale in localeMap; +/** Change the active locale for every registered system and persist the choice. */ +export function setLocale(locale: string): Promise { + if (!isLocale(locale)) { + return Promise.resolve(); } + localStorage.setItem(preferredLocaleKey, locale); + document.documentElement.lang = locale; + return loadAllAndActivate(locale); } -type TranslationProviderProps = { - translation: Translation; - children: React.ReactNode; -}; +function TranslationProvider({ children }: { children: React.ReactNode }) { + // Re-key the provider on locale change so the whole subtree remounts. Plain `t` macro strings read the + // global i18n but don't subscribe to context updates, so without a remount their labels stay stale + // after a locale switch while ``/`useLingui` consumers update. + const [currentLocale, setCurrentLocale] = useState(() => i18n.locale as Locale); -function TranslationProvider({ children, translation }: Readonly) { - const [currentLocale, setCurrentLocale] = useState(i18n.locale as Locale); - - const value: TranslationContext = useMemo( - () => ({ - currentLocale, - setLocale: async (locale: string) => { - await translation.dynamicActivate(locale); - setCurrentLocale(locale as Locale); // Update state to force re-render - }, - locales: translation.locales, - getLocaleInfo: translation.getLocaleInfo - }), - [translation, currentLocale] - ); + useEffect(() => { + const unsubscribe = i18n.on("change", () => setCurrentLocale(i18n.locale as Locale)); + return unsubscribe; + }, []); useEffect(() => { - const handleLocaleChangeRequest = async (event: Event) => { - const locale = (event as CustomEvent).detail.locale; - await value.setLocale(locale); + const handleLocaleChangeRequest = (event: Event) => { + void setLocale((event as CustomEvent).detail.locale); }; document.addEventListener("locale-change-request", handleLocaleChangeRequest); return () => document.removeEventListener("locale-change-request", handleLocaleChangeRequest); - }, [value]); + }, []); + + const value: TranslationContextValue = useMemo( + () => ({ currentLocale, setLocale, locales, getLocaleInfo }), + [currentLocale] + ); return ( @@ -157,3 +207,42 @@ function TranslationProvider({ children, translation }: Readonly ); } + +export const Translation = { + /** + * Bootstrap translations for a host SPA: register the shared-UI and host catalogs, then load and + * activate the initial locale before the app renders. + */ + async create(ownLoader: LocalLoaderFunction): Promise<{ TranslationProvider: typeof TranslationProvider }> { + registerCatalog(sharedUiSystemId, sharedUiLoader); + registerCatalog("host", ownLoader); + await loadAllAndActivate(resolveInitialLocale()); + return { TranslationProvider }; + } +}; + +/** + * Wrap a federated module so it contributes its own catalog to the shared dictionary. The system + * self-registers (no host needs to know it exists) and suspends until its catalog for the active locale + * is merged, preventing a flash of untranslated content inside the federated subtree. + */ +export function withSystemTranslations(systemId: string, loader: LocalLoaderFunction) { + registerCatalog(systemId, loader); + return function wrap

(Component: ComponentType

) { + function SystemTranslated(props: P) { + const { i18n: activeI18n } = useLingui(); + const locale = activeI18n.locale as Locale; + if (!isLoaded(systemId, locale)) { + throw ensureSystemActive(systemId, loader, locale); + } + return ; + } + return function WithSystemTranslations(props: P) { + return ( + + + + ); + }; + }; +} diff --git a/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts b/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts deleted file mode 100644 index 75ebb302d8..0000000000 --- a/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { Messages } from "@lingui/core"; - -import { type Locale, type LocaleFile, Translation } from "./Translation"; - -// Module federation container type -type FederatedContainer = { - get(module: string): Promise<() => { messages: Messages }>; -}; - -// Cache for loaded translation modules -const translationModuleCache = new Map(); - -/** - * Configuration for federated translations - * Each application that consumes federated modules should configure - * which remotes might provide translations - */ -const FEDERATED_TRANSLATION_REMOTES = [ - "account" - // Add more remotes here as they are created -] as const; - -/** - * Creates a Translation instance that automatically loads and merges translations - * from all federated modules configured in the current application. - * - * This function: - * 1. Uses the base translation loader for the host application - * 2. Automatically discovers and loads translations from configured federated remotes - * 3. Merges all translations together with remote translations taking precedence - * - * @param baseLoader - Function to load base translations for the host application - * @returns Translation instance with federated translation support - */ -export function createFederatedTranslation(baseLoader: (locale: Locale) => Promise): Promise { - const federatedLoader = createFederatedLoader(baseLoader); - return Translation.create(federatedLoader); -} - -/** - * Try to load translations from a federated module - */ -async function loadRemoteTranslations(remoteName: string, locale: Locale): Promise { - // Check cache first - const cacheKey = `${remoteName}:${locale}`; - const cached = translationModuleCache.get(cacheKey); - if (cached) { - return cached; - } - - // Get container using RSBuild's naming convention (hyphens to underscores) - const containerName = remoteName.replace(/-/g, "_"); - const container = (window as unknown as Record)[containerName] as FederatedContainer | null; - - if (!container?.get) { - return null; - } - - try { - const factory = await container.get(`./translations/${locale}`); - const module = factory(); - - if (module?.messages) { - translationModuleCache.set(cacheKey, module.messages); - return module.messages; - } - } catch { - // Silently fail - the remote might not have translations for this locale - } - - return null; -} - -/** - * Load translations from `@repo/ui` (the shared component library catalog). - * These are merged underneath the host SPA's own messages so the host can override. - * - * `@repo/ui` is consumed as a built package (its `exports` map subpaths into `dist`), so a - * templated dynamic import cannot be resolved into a scannable directory by the bundler. Each - * locale is therefore imported explicitly; keep this map in sync with `i18n.config.ts`. - */ -const sharedCatalogLoaders: Record Promise<{ messages?: Messages }>> = { - "en-US": () => import("@repo/ui/translations/locale/en-US"), - "da-DK": () => import("@repo/ui/translations/locale/da-DK") -}; - -async function loadSharedTranslations(locale: Locale): Promise { - try { - const module = await sharedCatalogLoaders[locale]?.(); - return module?.messages ?? null; - } catch { - return null; - } -} - -/** - * Creates a translation loader that merges translations from federated modules - */ -function createFederatedLoader( - baseLoader: (locale: Locale) => Promise -): (locale: Locale) => Promise { - return async (locale: Locale): Promise => { - const [sharedMessages, baseMessages] = await Promise.all([loadSharedTranslations(locale), baseLoader(locale)]); - - // Precedence (last write wins): shared < own < federated remote - const allMessages = { ...sharedMessages, ...baseMessages.messages }; - - await Promise.all( - FEDERATED_TRANSLATION_REMOTES.map(async (remoteName) => { - const remoteMessages = await loadRemoteTranslations(remoteName, locale); - if (remoteMessages) { - Object.assign(allMessages, remoteMessages); - } - }) - ); - - return { messages: allMessages }; - }; -} From b4882a8c82c07843fee22be3050325896586d908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:07:26 +0200 Subject: [PATCH 06/24] Extract MobileMenuDialogs into its own file --- .../federated-modules/sideMenu/MobileMenu.tsx | 90 +----------------- .../sideMenu/MobileMenuDialogs.tsx | 91 +++++++++++++++++++ .../federated-modules/userMenu/UserMenu.tsx | 2 +- 3 files changed, 96 insertions(+), 87 deletions(-) create mode 100644 application/account/WebApp/federated-modules/sideMenu/MobileMenuDialogs.tsx diff --git a/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx b/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx index 40ca047de7..0309fa1455 100644 --- a/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx +++ b/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx @@ -1,8 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { trackInteraction, useTrackOpen } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; -import { authSyncService, type TenantSwitchedMessage } from "@repo/infrastructure/auth/AuthSyncService"; -import { loggedInPath } from "@repo/infrastructure/auth/constants"; import { useUserInfo } from "@repo/infrastructure/auth/hooks"; import { productName } from "@repo/infrastructure/branding"; import { Button } from "@repo/ui/components/Button"; @@ -22,88 +20,9 @@ import { useContext, useEffect, useState } from "react"; import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; -import { SupportDialog } from "../common/SupportDialog"; -import { SwitchingAccountLoader } from "../common/SwitchingAccountLoader"; -import { fetchTenants, switchTenantApi, type TenantInfo } from "../common/tenantUtils"; +import { fetchTenants, type TenantInfo } from "../common/tenantUtils"; import { MobileMenuContent } from "./MobileMenuContent"; -import { TenantSwitcherDrawer } from "./TenantSwitcherDrawer"; - -export function MobileMenuDialogs() { - const userInfo = useUserInfo(); - const [isTenantSwitcherOpen, setIsTenantSwitcherOpen] = useState(false); - const [isSwitching, setIsSwitching] = useState(false); - const [isSupportDialogOpen, setIsSupportDialogOpen] = useState(false); - const [tenants, setTenants] = useState([]); - - const currentTenantId = userInfo?.tenantId; - - useEffect(() => { - const handleOpenSupport = () => setIsSupportDialogOpen(true); - const handleOpenTenantSwitcher = (event: CustomEvent<{ tenants: TenantInfo[] }>) => { - setTenants(event.detail.tenants); - setIsTenantSwitcherOpen(true); - }; - - window.addEventListener("open-support-dialog", handleOpenSupport); - window.addEventListener("open-tenant-switcher", handleOpenTenantSwitcher as EventListener); - return () => { - window.removeEventListener("open-support-dialog", handleOpenSupport); - window.removeEventListener("open-tenant-switcher", handleOpenTenantSwitcher as EventListener); - }; - }, []); - - const handleTenantSwitch = async (tenant: TenantInfo) => { - if (tenant.tenantId === currentTenantId || tenant.isNew) { - return; - } - - trackInteraction("Switch account", "interaction"); - setIsSwitching(true); - setIsTenantSwitcherOpen(false); - - try { - localStorage.setItem("preferred-tenant", tenant.tenantId); - if (tenant.tenantName) { - localStorage.setItem(`tenant-name-${tenant.tenantId}`, tenant.tenantName); - } - - await switchTenantApi(tenant.tenantId); - - if (userInfo?.tenantId && userInfo?.id) { - const message: Omit = { - type: "TENANT_SWITCHED", - newTenantId: tenant.tenantId, - previousTenantId: userInfo.tenantId, - tenantName: tenant.tenantName || t`Unnamed account`, - userId: userInfo.id - }; - authSyncService.broadcast(message); - } - - if (window.location.pathname === "/") { - window.location.href = loggedInPath; - } else { - window.location.reload(); - } - } catch { - setIsSwitching(false); - } - }; - - return ( - <> - - {isSwitching && } - - - ); -} +import { MobileMenuDialogs } from "./MobileMenuDialogs"; export interface MobileMenuProps { onNavigate?: (path: string) => void; @@ -223,9 +142,8 @@ function MobileMenu({ onNavigate }: Readonly) { - {/* Mounts the SupportDialog + TenantSwitcherDrawer event listeners. On desktop these are - mounted inside UserMenu (in the sidebar header), but the header isn't rendered in the - mobile overlay, so we mount them here too. */} + {/* Mounts the SupportDialog + TenantSwitcherDrawer event listeners. On desktop these mount + inside UserMenu's sidebar header, which the mobile overlay omits, so we mount them here too. */} ); diff --git a/application/account/WebApp/federated-modules/sideMenu/MobileMenuDialogs.tsx b/application/account/WebApp/federated-modules/sideMenu/MobileMenuDialogs.tsx new file mode 100644 index 0000000000..bda16f5cfe --- /dev/null +++ b/application/account/WebApp/federated-modules/sideMenu/MobileMenuDialogs.tsx @@ -0,0 +1,91 @@ +import { t } from "@lingui/core/macro"; +import { trackInteraction } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; +import { authSyncService, type TenantSwitchedMessage } from "@repo/infrastructure/auth/AuthSyncService"; +import { loggedInPath } from "@repo/infrastructure/auth/constants"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { useEffect, useState } from "react"; + +import { SupportDialog } from "../common/SupportDialog"; +import { SwitchingAccountLoader } from "../common/SwitchingAccountLoader"; +import { switchTenantApi, type TenantInfo } from "../common/tenantUtils"; +import { TenantSwitcherDrawer } from "./TenantSwitcherDrawer"; + +// Mounts the SupportDialog + TenantSwitcherDrawer and their global event listeners. Rendered by both the +// mobile menu (MobileMenu) and, on desktop, the sidebar header (UserMenu) — the two surfaces where a user +// can open the support dialog or switch tenants. +export function MobileMenuDialogs() { + const userInfo = useUserInfo(); + const [isTenantSwitcherOpen, setIsTenantSwitcherOpen] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + const [isSupportDialogOpen, setIsSupportDialogOpen] = useState(false); + const [tenants, setTenants] = useState([]); + + const currentTenantId = userInfo?.tenantId; + + useEffect(() => { + const handleOpenSupport = () => setIsSupportDialogOpen(true); + const handleOpenTenantSwitcher = (event: CustomEvent<{ tenants: TenantInfo[] }>) => { + setTenants(event.detail.tenants); + setIsTenantSwitcherOpen(true); + }; + + window.addEventListener("open-support-dialog", handleOpenSupport); + window.addEventListener("open-tenant-switcher", handleOpenTenantSwitcher as EventListener); + return () => { + window.removeEventListener("open-support-dialog", handleOpenSupport); + window.removeEventListener("open-tenant-switcher", handleOpenTenantSwitcher as EventListener); + }; + }, []); + + const handleTenantSwitch = async (tenant: TenantInfo) => { + if (tenant.tenantId === currentTenantId || tenant.isNew) { + return; + } + + trackInteraction("Switch account", "interaction"); + setIsSwitching(true); + setIsTenantSwitcherOpen(false); + + try { + localStorage.setItem("preferred-tenant", tenant.tenantId); + if (tenant.tenantName) { + localStorage.setItem(`tenant-name-${tenant.tenantId}`, tenant.tenantName); + } + + await switchTenantApi(tenant.tenantId); + + if (userInfo?.tenantId && userInfo?.id) { + const message: Omit = { + type: "TENANT_SWITCHED", + newTenantId: tenant.tenantId, + previousTenantId: userInfo.tenantId, + tenantName: tenant.tenantName || t`Unnamed account`, + userId: userInfo.id + }; + authSyncService.broadcast(message); + } + + if (window.location.pathname === "/") { + window.location.href = loggedInPath; + } else { + window.location.reload(); + } + } catch { + setIsSwitching(false); + } + }; + + return ( + <> + + {isSwitching && } + + + ); +} diff --git a/application/account/WebApp/federated-modules/userMenu/UserMenu.tsx b/application/account/WebApp/federated-modules/userMenu/UserMenu.tsx index c10de142f3..164ea4e2d5 100644 --- a/application/account/WebApp/federated-modules/userMenu/UserMenu.tsx +++ b/application/account/WebApp/federated-modules/userMenu/UserMenu.tsx @@ -21,7 +21,7 @@ import { withAccountTranslations } from "@/shared/translations/withAccountTransl import { SupportDialog } from "../common/SupportDialog"; import { SwitchingAccountLoader } from "../common/SwitchingAccountLoader"; import { logoutApi } from "../common/tenantUtils"; -import { MobileMenuDialogs } from "../sideMenu/MobileMenu"; +import { MobileMenuDialogs } from "../sideMenu/MobileMenuDialogs"; import { UserMenuDropdownContent } from "./UserMenuDropdownContent"; import { useSidebarWidth } from "./useSidebarWidth"; import { useUserMenuTenants } from "./useUserMenuTenants"; From 6a2eda97e7eada6c0432e21188bc306946277b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:07:40 +0200 Subject: [PATCH 07/24] Make @repo/ui translations self-contained via a RepoUiTranslation provider --- application/account/BackOffice/main.tsx | 11 +-- application/main/WebApp/bootstrap.tsx | 11 +-- .../translations/RepoUiTranslation.tsx | 10 +++ .../translations/Translation.tsx | 67 ++++++++++++------- .../ui/translations/loadUiTranslations.ts | 15 +++++ 5 files changed, 83 insertions(+), 31 deletions(-) create mode 100644 application/shared-webapp/infrastructure/translations/RepoUiTranslation.tsx create mode 100644 application/shared-webapp/ui/translations/loadUiTranslations.ts diff --git a/application/account/BackOffice/main.tsx b/application/account/BackOffice/main.tsx index 8e66caacbb..44d0b6dcf2 100644 --- a/application/account/BackOffice/main.tsx +++ b/application/account/BackOffice/main.tsx @@ -2,6 +2,7 @@ import "@repo/ui/theme.css"; import "@repo/ui/tailwind.css"; import { ApplicationInsightsProvider } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; import { setupGlobalErrorHandlers } from "@repo/infrastructure/http/globalErrorHandlers"; +import { RepoUiTranslation } from "@repo/infrastructure/translations/RepoUiTranslation"; import { Translation } from "@repo/infrastructure/translations/Translation"; import { Toaster } from "@repo/ui/components/Sonner"; import { RouterProvider } from "@tanstack/react-router"; @@ -25,10 +26,12 @@ setupGlobalErrorHandlers(); reactDom.createRoot(rootElement).render( - - - - + + + + + + ); diff --git a/application/main/WebApp/bootstrap.tsx b/application/main/WebApp/bootstrap.tsx index 8e66caacbb..44d0b6dcf2 100644 --- a/application/main/WebApp/bootstrap.tsx +++ b/application/main/WebApp/bootstrap.tsx @@ -2,6 +2,7 @@ import "@repo/ui/theme.css"; import "@repo/ui/tailwind.css"; import { ApplicationInsightsProvider } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; import { setupGlobalErrorHandlers } from "@repo/infrastructure/http/globalErrorHandlers"; +import { RepoUiTranslation } from "@repo/infrastructure/translations/RepoUiTranslation"; import { Translation } from "@repo/infrastructure/translations/Translation"; import { Toaster } from "@repo/ui/components/Sonner"; import { RouterProvider } from "@tanstack/react-router"; @@ -25,10 +26,12 @@ setupGlobalErrorHandlers(); reactDom.createRoot(rootElement).render( - - - - + + + + + + ); diff --git a/application/shared-webapp/infrastructure/translations/RepoUiTranslation.tsx b/application/shared-webapp/infrastructure/translations/RepoUiTranslation.tsx new file mode 100644 index 0000000000..a6bcb1c1cf --- /dev/null +++ b/application/shared-webapp/infrastructure/translations/RepoUiTranslation.tsx @@ -0,0 +1,10 @@ +import { loadUiTranslations } from "@repo/ui/translations/loadUiTranslations"; + +import { createSystemTranslationProvider } from "./Translation"; + +// App-level provider that contributes `@repo/ui`'s own catalog to the shared translation dictionary. +// Applications render `` around their tree so the shared component library's strings +// are registered and loaded — without the host having to know they exist. It lives here (not in +// `@repo/ui`) because the translation registry is in `@repo/infrastructure`, and `@repo/ui` sits below +// infrastructure in the dependency graph; `@repo/ui` owns the catalog and the loader, this owns the wiring. +export const RepoUiTranslation = createSystemTranslationProvider("shared-ui", loadUiTranslations); diff --git a/application/shared-webapp/infrastructure/translations/Translation.tsx b/application/shared-webapp/infrastructure/translations/Translation.tsx index 7a02097a0a..12a18bc5e0 100644 --- a/application/shared-webapp/infrastructure/translations/Translation.tsx +++ b/application/shared-webapp/infrastructure/translations/Translation.tsx @@ -63,19 +63,6 @@ const store: TranslationStore = ((globalThis as Record).__appTr activateInflight: new Map() } satisfies TranslationStore) as TranslationStore; -// `@repo/ui` is consumed as a built package (its `exports` map subpaths into `dist`), so a templated -// dynamic import cannot be resolved into a scannable directory by the bundler. Each locale is imported -// explicitly; keep this map in sync with `i18n.config.ts`. -const sharedUiSystemId = "shared-ui"; -const sharedUiCatalogs: Record Promise<{ messages?: Messages }>> = { - "en-US": () => import("@repo/ui/translations/locale/en-US"), - "da-DK": () => import("@repo/ui/translations/locale/da-DK") -}; -const sharedUiLoader: LocalLoaderFunction = async (locale) => { - const module = await sharedUiCatalogs[locale]?.(); - return { messages: module?.messages ?? {} }; -}; - export function registerCatalog(systemId: string, loader: LocalLoaderFunction): void { if (!store.loaders.has(systemId)) { store.loaders.set(systemId, loader); @@ -214,13 +201,34 @@ export const Translation = { * activate the initial locale before the app renders. */ async create(ownLoader: LocalLoaderFunction): Promise<{ TranslationProvider: typeof TranslationProvider }> { - registerCatalog(sharedUiSystemId, sharedUiLoader); registerCatalog("host", ownLoader); await loadAllAndActivate(resolveInitialLocale()); return { TranslationProvider }; } }; +/** + * Blocks its subtree until `systemId`'s catalog for the active locale has merged into the shared + * dictionary — suspending (via a thrown promise) while it loads. Shared by the federated-module HOC and + * the app-level package provider below. + */ +function SystemTranslationGate({ + systemId, + loader, + children +}: { + systemId: string; + loader: LocalLoaderFunction; + children: React.ReactNode; +}) { + const { i18n: activeI18n } = useLingui(); + const locale = activeI18n.locale as Locale; + if (!isLoaded(systemId, locale)) { + throw ensureSystemActive(systemId, loader, locale); + } + return <>{children}; +} + /** * Wrap a federated module so it contributes its own catalog to the shared dictionary. The system * self-registers (no host needs to know it exists) and suspends until its catalog for the active locale @@ -229,20 +237,33 @@ export const Translation = { export function withSystemTranslations(systemId: string, loader: LocalLoaderFunction) { registerCatalog(systemId, loader); return function wrap

(Component: ComponentType

) { - function SystemTranslated(props: P) { - const { i18n: activeI18n } = useLingui(); - const locale = activeI18n.locale as Locale; - if (!isLoaded(systemId, locale)) { - throw ensureSystemActive(systemId, loader, locale); - } - return ; - } return function WithSystemTranslations(props: P) { return ( - + + + ); }; }; } + +/** + * Build an app-level provider that contributes a bundled shared-webapp package's own catalog to the + * shared dictionary. Unlike a federated module (which decorates each exposed export), a bundled package + * is used throughout the app, so the application renders this provider once around its tree. The package + * owns its loader; this only registers it and gates rendering until it's merged. + */ +export function createSystemTranslationProvider(systemId: string, loader: LocalLoaderFunction) { + registerCatalog(systemId, loader); + return function SystemTranslationProvider({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); + }; +} diff --git a/application/shared-webapp/ui/translations/loadUiTranslations.ts b/application/shared-webapp/ui/translations/loadUiTranslations.ts new file mode 100644 index 0000000000..5476544796 --- /dev/null +++ b/application/shared-webapp/ui/translations/loadUiTranslations.ts @@ -0,0 +1,15 @@ +import type { Messages } from "@lingui/core"; + +// `@repo/ui` owns its own catalog. This loader is what makes the shared component library's +// translations self-contained: the package knows which locales it ships and how to load them, and the +// application wires it in via `` (see @repo/infrastructure/translations). Keep the +// locale keys in sync with the shared i18n config. +const catalogs: Record Promise<{ messages?: Messages }>> = { + "en-US": () => import("./locale/en-US"), + "da-DK": () => import("./locale/da-DK") +}; + +export async function loadUiTranslations(locale: string): Promise<{ messages: Messages }> { + const module = await catalogs[locale]?.(); + return { messages: module?.messages ?? {} }; +} From 6b420a4b5ed43ee06ea3ccd86cc17e794b900921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:08:03 +0200 Subject: [PATCH 08/24] Adopt Lingui context for translation disambiguation --- .claude/rules/frontend/translations.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/rules/frontend/translations.md b/.claude/rules/frontend/translations.md index 68cd420887..33fca305fc 100644 --- a/.claude/rules/frontend/translations.md +++ b/.claude/rules/frontend/translations.md @@ -20,13 +20,14 @@ Guidelines for implementing translations and internationalization in the fronten - At runtime, `createFederatedTranslation` merges them: shared underneath, system on top, federated remote (e.g. account loaded into main) on top of that - Translate each shared string **once** in the shared catalog, not in every system's catalog - Prefer Lingui macros inside `shared-webapp/ui` components over threading translatable text through props -- only accept a text prop when a consumer genuinely needs to override the default (e.g., context-specific wording) + - **Disambiguate colliding strings with `context`**: the merged dictionary keys by message ID (a hash of source text **+ `context`**), so identical bare source strings share one translation. When the same word must translate differently by area (e.g. "Account" in the auth flow vs. in the product), add `context` -- either per marker (`Account`, `` t({ message: "Account", context: "auth" }) ``) or once for a block/file with a Lingui **Context Directive** (`// lingui-set context="auth"` … `// lingui-reset`; supported by `@lingui/swc-plugin`). `context` feeds the ID hash so both translations coexist in the one dictionary. Don't blanket-namespace a whole system with `context` -- that defeats sharing of genuinely-common strings; use it only where meaning actually diverges 7. Translation workflow: - After adding/changing `` or t\`\` markers, the `*.po` files are auto-regenerated on build - Don't manually add or remove `msgid` entries -- only translate `msgstr` values - After auto-generation, translate all new/updated entries for all supported languages 8. **Use correct language-specific characters** in translations -- e.g., Danish requires æøå/ÆØÅ, not ae/oe/aa substitutes. Never approximate with ASCII equivalents 9. Don't translate fully dynamic content such as variable values or dynamic text -9. **Domain terminology consistency**: +10. **Domain terminology consistency**: - Use consistent terminology throughout the application - Before translating, check existing `*.po` files for established domain terms - If "Tenant" is used, always use "Tenant" (not "Customer", "Client", "Organization", etc.) From 53176f4e1717ff4404a9d01c5c795cc35745628f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:08:03 +0200 Subject: [PATCH 09/24] Document the frontend build system and deferred TypeScript 7 and React Compiler upgrades --- .claude/rules/frontend/build-system.md | 86 ++++++++++++++++++++++++++ .claude/rules/frontend/translations.md | 2 +- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .claude/rules/frontend/build-system.md diff --git a/.claude/rules/frontend/build-system.md b/.claude/rules/frontend/build-system.md new file mode 100644 index 0000000000..ed0b1238f4 --- /dev/null +++ b/.claude/rules/frontend/build-system.md @@ -0,0 +1,86 @@ +--- +paths: application/shared-webapp/*/rslib.config.ts,application/*/WebApp/rsbuild.config.ts,application/*/BackOffice/rsbuild.config.ts,application/shared-webapp/build/plugin/*.ts,application/package.json +description: How the frontend build system is wired (rslib libraries, rsbuild apps, module federation) and which build-tool upgrades are intentionally deferred +--- + +# Frontend Build System + +The shared libraries under `application/shared-webapp/` (`@repo/ui`, `@repo/infrastructure`, `@repo/utils`, +`@repo/build`) are built with **rslib** (ESM, bundleless) and consumed from their `dist` output. The three +SPAs (`main/WebApp`, `account/WebApp`, `account/BackOffice`) are built with **rsbuild**, and **module +federation lives only in the WebApp rsbuild builds** — rslib builds plain libraries, it does not produce +federation remotes. Understand these constraints before changing any `rslib.config.ts`, an app's +`rsbuild.config.ts`, or the toolchain versions in `application/package.json`. + +## Implementation + +1. **Build shared libraries with rslib, consume from `dist`.** Each library has an `rslib.config.ts` + (`bundle: false`, `format: "esm"`, `dts: true`). Their `package.json` `exports` map subpaths into + `dist`; there is no `source` export condition and the WebApps do not use `pluginSourceBuild`. Turbo's + `^build` ordering builds libraries before the apps. + +2. **Run the Lingui macro transform inside any rslib library that uses macros.** Because a library is + pre-built (not transformed by the consuming app), its `rslib.config.ts` must include `LinguiPlugin()` + (which injects `@lingui/swc-plugin`) in `plugins`, or `` / `` t` ` `` macros ship untransformed + and throw `Trans is not defined` / `t is not defined` at runtime. Today only `@repo/ui` uses macros, so + only its config needs it — add `LinguiPlugin()` to any other library that introduces Lingui macros. + `@lingui/swc-plugin` runs against rspack's embedded SWC (now in both the WebApp rsbuild builds and this + rslib build), and the SWC plugin API is not semver-stable — it's pinned to an exact version, so re-check + compatibility (plugins.swc.rs) whenever `@rspack/binding` or `@lingui/swc-plugin` is bumped, or the build + fails with `LayoutError` / "failed to run Wasm plugin transform". + +3. **Give rslib the asset extension for images imported by library components.** Raster/vector assets a + library component imports (e.g. `@repo/ui` `Logo.tsx` importing `../images/*.png`) must have their + extension in the library's rslib `source.entry.index` glob (`./**/*.png`, `./**/*.webp`, `./**/*.svg`) + so rslib emits an asset module and rewrites the import correctly. Package-path imports from apps + (`@repo/ui/images/x.webp`) resolve through the `./*.webp` / `./*.png` `exports`; rslib emits the raw + assets there unhashed via `output.distPath.image: "images"` + `output.filename.image: "[name][ext]"` (no + hand-rolled copy step). Node-side build code must be ESM-safe (`import.meta.dirname`, + not `__dirname`; read JSON with `fs`, not `require()`). + +4. **Keep `react`, `react-dom`, `@lingui/core`, `@lingui/react` as module-federation `shared` singletons** + (`application/shared-webapp/build/plugin/ModuleFederationPlugin.ts`). The translation system depends on a + single shared Lingui `i18n` across remotes — see [translations](/.claude/rules/frontend/translations.md). + +5. **TypeScript 7 is intentionally deferred — stay on the 6.x line.** TS 7's native compiler removed the + classic programmatic API (its `package.json` exposes no main export, only `./unstable/*`), so + `openapi-typescript` (the `swagger` codegen) and rslib's `dts` generator both fail to load it, and it + cannot be the root `typescript`. Only the `tsc` CLI works. Revisit when those tools support the native + compiler (≈ TS 7.1). If a TS 7 typecheck-only gate is wanted before then, run it in parallel via `tsgo` + from `@typescript/native-preview` (a separate binary, no `typescript` peer conflict) — do **not** bump + the root `typescript` to 7. + +6. **The Rust React Compiler is deferred to a follow-up PR.** It is the official React Compiler ported to + Rust and integrated via SWC, shipped in **Rspack/Rsbuild 2.1**; this repo is on 2.0.x. To adopt: bump + `@rsbuild/core` and `@rspack/binding` to 2.1, enable it on `@rsbuild/plugin-react` (or via + `builtin:swc-loader` `jsc.transform.reactCompiler`), and validate the component set. The federation + singletons in step 4 are already the prerequisite. + +7. **After changing rules under `.claude/`, run `dotnet run --project developer-cli -- sync-ai-rules --quiet`.** + +## Examples + +### Example 1 - Lingui macros in an rslib library config + +```ts +// ✅ DO: a library that uses /t must run the Lingui SWC plugin during its own build +import { LinguiPlugin } from "@repo/build/plugin/LinguiPlugin"; +import { pluginReact } from "@rsbuild/plugin-react"; +import { defineConfig } from "@rslib/core"; + +export default defineConfig({ + source: { entry: { index: ["./**/*.tsx", "./**/*.ts", "./**/*.svg", "./**/*.css", "./**/*.png", "!rslib.config.ts", "!node_modules/**", "!dist/**"] } }, + lib: [{ bundle: false, dts: true, format: "esm" }], + output: { target: "web" }, + plugins: [pluginReact(), LinguiPlugin()] +}); + +// ❌ DON'T: omit LinguiPlugin() — macros ship untransformed → "Trans is not defined" at runtime +``` + +### Example 2 - Enabling the React Compiler later (Rsbuild/Rspack 2.1) + +```ts +// After bumping @rsbuild/core and @rspack/binding to 2.1: +pluginReact({ reactCompiler: true }); +``` diff --git a/.claude/rules/frontend/translations.md b/.claude/rules/frontend/translations.md index 33fca305fc..7df5cdf42e 100644 --- a/.claude/rules/frontend/translations.md +++ b/.claude/rules/frontend/translations.md @@ -17,7 +17,7 @@ Guidelines for implementing translations and internationalization in the fronten 6. **Catalogs are split between the shared component library and each self-contained system**: - Markers in `application/shared-webapp/ui/**` extract to `application/shared-webapp/ui/translations/locale/{locale}.po` — one shared catalog used by every app - Markers in `application//WebApp/**` extract to that system's `shared/translations/locale/{locale}.po` - - At runtime, `createFederatedTranslation` merges them: shared underneath, system on top, federated remote (e.g. account loaded into main) on top of that + - At runtime each system self-registers its catalog into one shared Lingui `i18n` (`Translation.create` for a host SPA; `withSystemTranslations` for a federated module) and they merge: shared UI underneath, host system on top, federated remote (e.g. account loaded into main) on top of that. `@lingui/core`/`@lingui/react` are module-federation singletons so every remote reads the same merged dictionary - Translate each shared string **once** in the shared catalog, not in every system's catalog - Prefer Lingui macros inside `shared-webapp/ui` components over threading translatable text through props -- only accept a text prop when a consumer genuinely needs to override the default (e.g., context-specific wording) - **Disambiguate colliding strings with `context`**: the merged dictionary keys by message ID (a hash of source text **+ `context`**), so identical bare source strings share one translation. When the same word must translate differently by area (e.g. "Account" in the auth flow vs. in the product), add `context` -- either per marker (`Account`, `` t({ message: "Account", context: "auth" }) ``) or once for a block/file with a Lingui **Context Directive** (`// lingui-set context="auth"` … `// lingui-reset`; supported by `@lingui/swc-plugin`). `context` feeds the ID hash so both translations coexist in the one dictionary. Don't blanket-namespace a whole system with `context` -- that defeats sharing of genuinely-common strings; use it only where meaning actually diverges From 6bdbd70f4a302f3c250b35d3a0314b4aa113c172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:04:05 +0200 Subject: [PATCH 10/24] Route HMR WebSocket through the AppGateway --- application/AppGateway/appsettings.json | 18 ++++++++++++++++++ application/account/WebApp/rsbuild.config.ts | 2 +- .../build/plugin/DevelopmentServerPlugin.ts | 16 +++++++++++++--- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/application/AppGateway/appsettings.json b/application/AppGateway/appsettings.json index 49fa5c1811..4932c4f1ec 100644 --- a/application/AppGateway/appsettings.json +++ b/application/AppGateway/appsettings.json @@ -222,6 +222,15 @@ } ] }, + "account-hmr-ws": { + "ClusterId": "account-static", + "Match": { + "Path": "/account/rsbuild-hmr" + }, + "Metadata": { + "HostnameKey": "App" + } + }, "main-static": { "ClusterId": "main-static", "Match": { @@ -240,6 +249,15 @@ "HostnameKey": "App" } }, + "main-hmr-ws": { + "ClusterId": "main-static", + "Match": { + "Path": "/rsbuild-hmr" + }, + "Metadata": { + "HostnameKey": "App" + } + }, "main": { "ClusterId": "main-api", "Match": { diff --git a/application/account/WebApp/rsbuild.config.ts b/application/account/WebApp/rsbuild.config.ts index faa6cbadab..71d4a8591d 100644 --- a/application/account/WebApp/rsbuild.config.ts +++ b/application/account/WebApp/rsbuild.config.ts @@ -47,7 +47,7 @@ export default defineConfig({ FileSystemRouterPlugin(), RunTimeEnvironmentPlugin(customBuildEnv, { federationOnly: true }), LinguiPlugin(), - DevelopmentServerPlugin({ port: accountStaticPort, liveReload: false }), + DevelopmentServerPlugin({ port: accountStaticPort, liveReload: false, hmrPath: "/account/rsbuild-hmr" }), ModuleFederationPlugin({ exposes: { "./AccessDeniedPage": "./federated-modules/errorPages/AccessDeniedPage.tsx", diff --git a/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts b/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts index d3cb7eb7e2..20d07daeb7 100644 --- a/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts +++ b/application/shared-webapp/build/plugin/DevelopmentServerPlugin.ts @@ -38,6 +38,12 @@ export type DevelopmentServerPluginOptions = { * update from reloading the host page while the remote is still rebuilding. */ liveReload?: boolean; + /** + * The HMR WebSocket path. Defaults to rsbuild's `/rsbuild-hmr`. Systems that share an origin with the + * host (e.g. the `account` remote loaded into `main` under `app.dev.localhost`) must use a distinct + * path so the gateway can route each system's WebSocket to its own dev server. + */ + hmrPath?: string; }; /** @@ -101,9 +107,13 @@ export function DevelopmentServerPlugin(options: DevelopmentServerPluginOptions) } }, dev: { - client: { - port: options.port - }, + // Leave client host/port unset so the HMR WebSocket targets the page's own origin -- the + // AppGateway (app.dev.localhost: for main/account) or the BackOffice Kestrel -- + // which forwards the upgrade to this dev server, matching how every other dev asset is served. + // rsbuild's client then uses location.host/port instead of pinning the dev server's own port, + // which would bypass the gateway. Co-hosted systems (main + the account remote) share one + // origin, so each passes a distinct hmrPath for the gateway to route each WebSocket on. + client: options.hmrPath ? { path: options.hmrPath } : {}, liveReload: options.liveReload ?? true, // Set publicPath to auto to enable the server to serve the files assetPrefix: "auto", From 687f42005e50965194f6424a5da93c0e0c74ae30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:56:21 +0200 Subject: [PATCH 11/24] Add Lingui i18n, SWC plugin compatibility, and message context skills --- .../skills/enhanced-message-context/SKILL.md | 229 ++++++++++ .claude/skills/lingui-best-practices/SKILL.md | 390 ++++++++++++++++ .../references/common-mistakes.md | 418 ++++++++++++++++++ .../references/configuration.md | 173 ++++++++ .../skills/swc-plugin-compatibility/SKILL.md | 116 +++++ 5 files changed, 1326 insertions(+) create mode 100644 .claude/skills/enhanced-message-context/SKILL.md create mode 100644 .claude/skills/lingui-best-practices/SKILL.md create mode 100644 .claude/skills/lingui-best-practices/references/common-mistakes.md create mode 100644 .claude/skills/lingui-best-practices/references/configuration.md create mode 100644 .claude/skills/swc-plugin-compatibility/SKILL.md diff --git a/.claude/skills/enhanced-message-context/SKILL.md b/.claude/skills/enhanced-message-context/SKILL.md new file mode 100644 index 0000000000..32d2d26495 --- /dev/null +++ b/.claude/skills/enhanced-message-context/SKILL.md @@ -0,0 +1,229 @@ +--- +name: enhanced-message-context +description: Provide additional context for messages based on the codebase and the context of the message to improve the quality of the translations. +--- + +# Enhanced Message Context + +When implementing Lingui i18n, add translator comments to messages so translators have context to provide the best translation. Even when the message text is self-explanatory, it is important to know where and how it appears in the UI to choose the correct tone, length, and wording. + +## When to Add Comments + +Add a `comment` field when the message: + +- **Is ambiguous**: Short words/phrases that can be different parts of speech + - "Back" (noun or verb?), "Delete" (button or confirmation?), "Close" (verb or adjective?) +- **Lacks UI context**: Labels isolated from their surroundings + - Table column headers, tooltip content, standalone button labels, menu items +- **Has domain-specific meaning**: Terms with different meanings across contexts + - "Post" (verb or noun?), "Tag" (noun or verb?), "Follow" (social media or instruction?) +- **Depends on grammatical gender**: The translation depends on what the message refers to + - "Selected" (masculine/feminine/neutral depends on what is selected) +- **Uses unclear variables**: Placeholder names don't reveal what they contain + - `{count}` (count of what?), `{name}` (user name, file name, project name?) +- **Could benefit from UI context even if clear**: Where the text appears (button, dialog, banner, form field) affects tone and length - add a brief location or purpose comment when it helps. + +## Writing Effective Comments + +A good translator comment includes: + +1. **Location**: Where in the UI the message appears + - "Button in the top navigation bar" + - "Tooltip for the save icon" + - "Column header in the users table" + +2. **Action/Purpose**: What happens or what it means + - "Navigates back to the previous page" + - "Deletes the selected item permanently" + - "Shows the number of unread notifications" + +3. **Disambiguation**: Clarify part of speech or meaning + - "Used as a verb, not a noun" + - "Refers to email addresses, not postal addresses" + - "Singular form, user will see 'item' or 'items' based on count" + +## API Reference + +Lingui provides three ways to add translator comments: + +### 1. JS Macro (`t`) + +For JavaScript code outside JSX: + +```js +import { t } from "@lingui/core/macro"; + +// With comment +const backLabel = t({ + comment: "Button in the navigation bar that returns to the previous page", + message: "Back", +}); + +// With comment and variable +const uploadSuccess = t({ + comment: "Success message showing the name of the file that was uploaded", + message: `File ${fileName} uploaded successfully`, +}); +``` + +### 2. React Macro (`Trans`) + +For JSX elements: + +```jsx +import { Trans } from "@lingui/react/macro"; + +// With comment +Delete + +// With comment in a component + +``` + +### 3. Deferred/Lazy Messages (`defineMessage` / `msg`) + +For messages defined separately from their usage: + +```js +import { defineMessage } from "@lingui/core/macro"; + +const messages = { + deleteButton: defineMessage({ + comment: "Button that permanently removes the item from the database", + message: "Delete", + }), + + statusLabel: defineMessage({ + comment: "Shows whether the service is currently operational. Values: 'Active', 'Inactive', 'Pending'", + message: "Status: {status}", + }), +}; +``` + +## Examples + +### Example 1: Ambiguous Short Word + +**Before** (no context): + +```jsx + +``` + +**After** (with context): + +```jsx + +``` + +### Example 2: UI Label Without Context + +**Before** (no context): + +```jsx +const columns = [ + { key: "name", label: t`Name` }, + { key: "status", label: t`Status` }, +]; +``` + +**After** (with context): + +```jsx +const columns = [ + { + key: "name", + label: t({ + comment: "Column header in the projects table showing project name", + message: "Name" + }) + }, + { + key: "status", + label: t({ + comment: "Column header showing project status: Active, Inactive, or Archived", + message: "Status" + }) + }, + { + key: "created", + label: t({ + comment: "Column header showing the date when the project was created", + message: "Created" + }) + }, +]; +``` + +### Example 3: Domain-Specific Term + +**Before** (ambiguous): + +```jsx + +``` + +**After** (clarified as verb): + +```jsx + +``` + +### Example 4: Variable Without Clear Meaning + +**Before** (unclear what count represents): + +```js +const message = t`${count} items selected`; +``` + +**After** (clarified): + +```js +const message = t({ + comment: "Shows the number of email messages currently selected in the inbox", + message: `${count} items selected`, +}); +``` + +### Example 5: Self-Explanatory Message (Comment Still Optional but Helpful) + +```jsx +// Message is clear on its own; adding a comment with location still helps translators + + Your password must contain at least 8 characters, including one uppercase letter and one number. + +``` + +## Workflow + +When implementing or reviewing Lingui messages: + +1. **Read the message**: Look at the string itself +2. **Check context**: Consider where and how it's used in the code +3. **Ask**: "Could a translator misinterpret this without seeing the UI?" +4. **If yes**: Add a `comment` field with location, purpose, and any disambiguation +5. **If no**: Skip the comment and keep the code clean + +## Notes + +- Comments are extracted into message catalogs for translators +- Comments are stripped from production builds (zero runtime cost) +- Comments appear in translation management systems (TMS) +- Use consistent terminology across all comments in your project diff --git a/.claude/skills/lingui-best-practices/SKILL.md b/.claude/skills/lingui-best-practices/SKILL.md new file mode 100644 index 0000000000..7cbbe5a0ff --- /dev/null +++ b/.claude/skills/lingui-best-practices/SKILL.md @@ -0,0 +1,390 @@ +--- +name: lingui-best-practices +description: Implement internationalization with Lingui in React and JavaScript applications. Use when adding i18n, translating UI, working with Trans/useLingui/Plural, extracting messages, compiling catalogs, or when the user mentions Lingui, internationalization, i18n, translations, locales, message extraction, ICU MessageFormat, or working with .po files. +--- + +# Lingui Best Practices + +Lingui is a powerful internationalization (i18n) framework for JavaScript. This skill covers best practices for implementing i18n in React and vanilla JavaScript applications. + +## Quick Start Workflow + +The standard Lingui workflow consists of these steps: + +1. Wrap your app in `I18nProvider` +2. Mark messages for translation using macros (`Trans`, `t`, etc.) +3. Extract messages: `lingui extract` +4. Translate the catalogs +5. Compile catalogs: `lingui compile` +6. Load and activate locale in your app + +## Core Packages + +Import from these packages: + +```jsx +// React macros (recommended) +import { Trans, Plural, Select, useLingui } from "@lingui/react/macro"; + +// Core macros for vanilla JS +import { t, msg, plural, select } from "@lingui/core/macro"; + +// Runtime (rarely used directly) +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +``` + +## Setup I18nProvider + +Wrap your application with `I18nProvider`: + +```jsx +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; +import { messages } from "./locales/en/messages"; + +i18n.load("en", messages); +i18n.activate("en"); + +function App() { + return ( + + {/* Your app */} + + ); +} +``` + +## Translating UI Text + +### Use Trans for JSX Content + +The `Trans` macro is the primary way to translate JSX: + +```jsx +import { Trans } from "@lingui/react/macro"; + +// Simple text +Hello World + +// With variables +Hello {userName} + +// With components (rich text) + + Read the documentation for more info. + + +// Extracted as: "Read the <0>documentation for more info." +``` + +**When to use**: For any translatable text in JSX elements. + +### Use useLingui for Non-JSX + +For strings outside JSX (attributes, alerts, function calls): + +```jsx +import { useLingui } from "@lingui/react/macro"; + +function MyComponent() { + const { t } = useLingui(); + + const handleClick = () => { + alert(t`Action completed!`); + }; + + return ( +

+ {t`Image + +
+ ); +} +``` + +**When to use**: Element attributes, alerts, function parameters, any non-JSX string. + +### Use msg for Lazy Translations + +When you need to define messages at module level or in arrays/objects: + +```jsx +import { msg } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; + +// Module-level constants +const STATUSES = { + active: msg`Active`, + inactive: msg`Inactive`, + pending: msg`Pending`, +}; + +function StatusList() { + const { _ } = useLingui(); + + return Object.entries(STATUSES).map(([key, message]) => ( +
{_(message)}
+ )); +} +``` + +**When to use**: Module-level constants, arrays of messages, conditional message selection. + +## Pluralization + +Use the `Plural` macro for quantity-dependent messages: + +```jsx +import { Plural } from "@lingui/react/macro"; + + +``` + +The `#` placeholder is replaced with the actual value. + +### Exact Matches + +Use `_N` syntax for exact number matches (takes precedence over plural forms): + +```jsx + +``` + +### With Variables and Components + +Combine with `Trans` for complex messages: + +```jsx + + You have # messages, {userName} +
+ } +/> +``` + +## Formatting Dates and Numbers + +Use `i18n.date()` and `i18n.number()` for locale-aware formatting: + +```jsx +import { useLingui } from "@lingui/react/macro"; + +function MyComponent() { + const { i18n } = useLingui(); + const lastLogin = new Date(); + + return ( + + Last login: {i18n.date(lastLogin)} + + ); +} +``` + +These use the browser's `Intl` API for proper locale formatting. + +## Message IDs and Context + +### Explicit IDs + +Provide a custom ID for stable message keys: + +```jsx +Welcome to our app +``` + +### Context for Disambiguation + +When the same text has different meanings, use `context`: + +```jsx +right +right +``` + +These create separate catalog entries. + +### Comments for Translators + +Add context for translators: + +```jsx +Hello World +``` + +## Configuration + +Basic `lingui.config.js`: + +```js +import { defineConfig } from "@lingui/cli"; + +export default defineConfig({ + sourceLocale: "en", + locales: ["en", "es", "fr", "de"], + catalogs: [ + { + path: "/src/locales/{locale}/messages", + include: ["src"], + exclude: ["**/node_modules/**"], + }, + ], +}); +``` + +For detailed configuration patterns, see [configuration.md](references/configuration.md). + +## Best Practices + +### Always Use Macros + +Prefer macros over runtime components. Macros are compiled at build time, reducing bundle size: + +```jsx +// ✅ Good - uses macro +import { Trans } from "@lingui/react/macro"; + +// ❌ Avoid - runtime only +import { Trans } from "@lingui/react"; +``` + +### Keep Messages Simple + +Avoid complex expressions in messages - they'll be replaced with placeholders: + +```jsx +// ❌ Bad - loses context +Hello {user.name.toUpperCase()} +// Extracted as: "Hello {0}" + +// ✅ Good - clear variable name +const userName = user.name.toUpperCase(); +Hello {userName} +// Extracted as: "Hello {userName}" +``` + +### Use Trans for JSX, t for Strings + +Choose the right tool: + +```jsx +// ✅ For JSX content +

Welcome

+ +// ✅ For string values +const { t } = useLingui(); +{t`Profile +``` + +### Don't Use Macros at Module Level + +Macros need component context - use `msg` instead: + +```jsx +// ❌ Bad - won't work +import { t } from "@lingui/core/macro"; +const LABELS = [t`Red`, t`Green`, t`Blue`]; + +// ✅ Good - use msg for lazy translation +import { msg } from "@lingui/core/macro"; +const LABELS = [msg`Red`, msg`Green`, msg`Blue`]; +``` + +### Use the ESLint Plugin + +Install and configure `eslint-plugin-lingui` to catch common mistakes automatically: + +```bash +npm install --save-dev eslint-plugin-lingui +``` + +```js +// eslint.config.js +import pluginLingui from "eslint-plugin-lingui"; + +export default [ + pluginLingui.configs["flat/recommended"], +]; +``` + +## Common Patterns + +### Dynamic Locale Switching + +```jsx +import { i18n } from "@lingui/core"; + +async function changeLocale(locale) { + const { messages } = await import(`./locales/${locale}/messages`); + i18n.load(locale, messages); + i18n.activate(locale); +} +``` + +### Loading Catalogs Dynamically + +```jsx +import { useEffect } from "react"; +import { i18n } from "@lingui/core"; + +function loadCatalog(locale) { + return import(`./locales/${locale}/messages`); +} + +function App() { + useEffect(() => { + loadCatalog("en").then(catalog => { + i18n.load("en", catalog.messages); + i18n.activate("en"); + }); + }, []); + + return {/* ... */}; +} +``` + +### Memoization with useLingui + +When using memoization, use the `t` function from the macro version: + +```jsx +import { useLingui } from "@lingui/react/macro"; +import { msg } from "@lingui/core/macro"; +import { useMemo } from "react"; + +const welcomeMessage = msg`Welcome!`; + +function MyComponent() { + const { t } = useLingui(); // Macro version - reference changes with locale + + // ✅ Safe - t reference updates with locale + const message = useMemo(() => t(welcomeMessage), [t]); + + return
{message}
; +} +``` + +## Troubleshooting + +If you encounter issues: + +1. **Messages not extracted**: Check `include` patterns in `lingui.config.js` +2. **Translations not applied**: Ensure catalogs are compiled with `lingui compile` +3. **Runtime errors**: Verify `I18nProvider` wraps your app +4. **Type errors**: Run `lingui compile --typescript` for TypeScript projects + +For detailed common mistakes and pitfalls, see [common-mistakes.md](references/common-mistakes.md). diff --git a/.claude/skills/lingui-best-practices/references/common-mistakes.md b/.claude/skills/lingui-best-practices/references/common-mistakes.md new file mode 100644 index 0000000000..a5964cc0a0 --- /dev/null +++ b/.claude/skills/lingui-best-practices/references/common-mistakes.md @@ -0,0 +1,418 @@ +# Common Mistakes and Pitfalls + +This document covers common mistakes when using Lingui and how to avoid them. + +## Module-Level Macro Usage + +### The Problem + +Using macros like `t` at module level won't react to locale changes: + +```jsx +// ❌ WRONG - This won't update when locale changes +import { t } from "@lingui/core/macro"; + +const COLORS = [ + t`Red`, + t`Green`, + t`Blue` +]; + +function ColorList() { + return COLORS.map(color =>
{color}
); +} +``` + +**Why it fails**: The `t` macro requires access to the active i18n context. At module level, the context isn't available and messages are evaluated only once. + +### The Solution + +Use the `msg` macro for lazy translations: + +```jsx +// ✅ CORRECT - Use msg for module-level messages +import { msg } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; + +const COLORS = [ + msg`Red`, + msg`Green`, + msg`Blue` +]; + +function ColorList() { + const { _ } = useLingui(); + return COLORS.map(color =>
{_(color)}
); +} +``` + +**Key points**: +- `msg` creates a message descriptor, not the final string +- Use `_()` or `i18n._()` to translate the descriptor at render time +- Messages will update when locale changes + +### Alternative Pattern + +If you need the messages in a React component: + +```jsx +// ✅ Also correct - define in component +import { useLingui } from "@lingui/react/macro"; + +function ColorList() { + const { t } = useLingui(); + + const colors = [t`Red`, t`Green`, t`Blue`]; + + return colors.map(color =>
{color}
); +} +``` + +## Plural Form Misunderstandings + +### Zero Form Doesn't Exist in English + +A common mistake is expecting English to have a `zero` plural form: + +```jsx +// ❌ WRONG - English doesn't have 'zero' form + +``` + +**Result**: When `count = 0`, this displays `"0 messages"`, not `"No messages"`. + +**Why**: English plural rules only have two forms: +- `one`: Used when value is exactly 1 +- `other`: Used for everything else (0, 2, 3, ...) + +### The Solution: Use Exact Matches + +Use the `_N` syntax for exact number matches: + +```jsx +// ✅ CORRECT - Use _0 for exact match + +``` + +**How it works**: +- `_0` matches exactly 0 (takes precedence over plural forms) +- `one` matches exactly 1 (plural form) +- `other` matches everything else (plural form) + +### Exact Matches vs Plural Forms + +You can use exact matches for any number: + +```jsx + +``` + +**Rule**: Exact matches (`_N`) always take precedence over plural forms (`one`, `few`, `many`, `other`). + +### Decimal Numbers + +Be aware that decimal numbers (even `1.0`) use the `other` form: + +```jsx + +// Shows: "1.0 items" (not "1.0 item") +``` + +## Memoization Pitfalls + +### The i18n Reference Problem + +A common mistake when using React hooks: + +```jsx +// ❌ WRONG - i18n reference is stable +import { useLingui } from "@lingui/react"; +import { msg } from "@lingui/core/macro"; +import { useMemo } from "react"; + +const welcomeMessage = msg`Welcome!`; + +function MyComponent() { + const { i18n } = useLingui(); + + const welcome = useMemo( + () => i18n._(welcomeMessage), + [i18n] // i18n reference doesn't change! + ); + + return
{welcome}
; +} +``` + +**Problem**: The `i18n` object reference is stable across re-renders. When locale changes, the component won't re-translate because the dependency (`i18n`) hasn't changed. + +### Solution 1: Use the Macro Version + +The easiest solution is to use the macro version of `useLingui`: + +```jsx +// ✅ CORRECT - Macro version provides 't' that changes +import { useLingui } from "@lingui/react/macro"; +import { msg } from "@lingui/core/macro"; +import { useMemo } from "react"; + +const welcomeMessage = msg`Welcome!`; + +function MyComponent() { + const { t } = useLingui(); + + const welcome = useMemo( + () => t(welcomeMessage), + [t] // t reference changes with locale + ); + + return
{welcome}
; +} +``` + +### Solution 2: Use the Underscore Function + +The runtime `useLingui` provides a `_` function that changes with locale: + +```jsx +// ✅ Also correct - underscore function changes +import { useLingui } from "@lingui/react"; +import { msg } from "@lingui/core/macro"; +import { useMemo } from "react"; + +// Define message descriptor outside component +const welcomeMessage = msg`Welcome!`; + +function MyComponent() { + const { _ } = useLingui(); + + const welcome = useMemo( + () => _(welcomeMessage), + [_] // _ reference changes with locale + ); + + return
{welcome}
; +} +``` + +### When Memoization Matters + +You typically need to consider this when: +- Using `useMemo` or `useCallback` with translations +- Creating refs or derived state from translated messages +- Passing translations to third-party components + +## Complex Expressions in Messages + +### The Problem + +Using complex expressions directly in messages loses context: + +```jsx +// ❌ BAD - Expression becomes a placeholder +Hello {user.name.toUpperCase()} +// Extracted as: "Hello {0}" + +Total: {items.reduce((sum, item) => sum + item.price, 0)} +// Extracted as: "Total: {0}" +``` + +**Problem**: Translators see `{0}` instead of meaningful variable names, losing important context. + +### The Solution + +Extract the value to a named variable first: + +```jsx +// ✅ GOOD - Variable has meaningful name +const userName = user.name.toUpperCase(); +Hello {userName} +// Extracted as: "Hello {userName}" + +const totalPrice = items.reduce((sum, item) => sum + item.price, 0); +Total: {totalPrice} +// Extracted as: "Total: {totalPrice}" +``` + +### Method Calls and Properties + +Even simple method calls should be extracted: + +```jsx +// ❌ BAD +Welcome {currentUser.getName()} + +// ✅ GOOD +const userName = currentUser.getName(); +Welcome {userName} +``` + +### ESLint Rule + +Enable the Lingui ESLint plugin to catch these automatically: + +```js +// eslint.config.js (flat config) +import pluginLingui from "eslint-plugin-lingui"; + +export default [ + pluginLingui.configs["flat/recommended"], +]; + +// Or configure individual rules: +// "lingui/no-expression-in-message": "error" +``` + +## Missing I18nProvider + +### The Problem + +Components using Lingui hooks or components without `I18nProvider`: + +```jsx +// ❌ WRONG - No I18nProvider +import { Trans } from "@lingui/react/macro"; + +function App() { + return ( +
+ Hello +
+ ); +} +``` + +**Error**: You'll get a runtime error: "Cannot read property 'locale' of undefined" or similar. + +### The Solution + +Always wrap your app with `I18nProvider`: + +```jsx +// ✅ CORRECT +import { I18nProvider } from "@lingui/react"; +import { i18n } from "@lingui/core"; + +i18n.load("en", messages); +i18n.activate("en"); + +function App() { + return ( + +
+ Hello +
+
+ ); +} +``` + +### Where to Place It + +Place `I18nProvider` as high as possible in your component tree: + +```jsx +// App entry point +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + +); +``` + +## Incorrect Import Paths (v5+) + +### The Problem + +Using old import paths from Lingui v4: + +```jsx +// ❌ WRONG in v5 +import { t, Trans } from "@lingui/macro"; +``` + +### The Solution + +Use split imports in v5: + +```jsx +// ✅ CORRECT in v5 +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +``` + +**Macro imports**: +- `@lingui/core/macro` - Core macros (t, msg, plural, select, etc.) +- `@lingui/react/macro` - React macros (Trans, Plural, Select, useLingui) + +**Runtime imports**: +- `@lingui/core` - Core runtime (i18n object, setupI18n) +- `@lingui/react` - React runtime (I18nProvider, Trans component, useLingui) + +## React Native: Missing Text Component + +### The Problem + +In React Native, translations render as strings by default, causing errors: + +```jsx +// ❌ WRONG in React Native + + Hello + +// Error: Text strings must be rendered within a component +``` + +### The Solution + +Configure `defaultComponent` in `I18nProvider`: + +```jsx +import { Text } from "react-native"; + + + + +``` + +Or use the `component` prop on individual components: + +```jsx +import { Text } from "react-native"; + +Hello +``` + +## Summary + +**Top mistakes to avoid**: + +1. ❌ Using `t` macro at module level → Use `msg` instead +2. ❌ Expecting English `zero` form → Use `_0` for exact matches +3. ❌ Memoizing with `i18n` reference → Use `t` from macro `useLingui` +4. ❌ Complex expressions in messages → Extract to named variables +5. ❌ Missing `I18nProvider` → Wrap your app +6. ❌ Loading uncompiled catalogs → Run `lingui compile` +7. ❌ Wrong imports in v5 → Use `@lingui/core/macro` and `@lingui/react/macro` +8. ❌ Forgetting extraction → Run `lingui extract` regularly + +Following these practices will help you avoid the most common Lingui pitfalls. diff --git a/.claude/skills/lingui-best-practices/references/configuration.md b/.claude/skills/lingui-best-practices/references/configuration.md new file mode 100644 index 0000000000..2e39bc55f7 --- /dev/null +++ b/.claude/skills/lingui-best-practices/references/configuration.md @@ -0,0 +1,173 @@ +# Configuration Patterns + +Essential patterns for `lingui.config.js` (or `.ts`). + +## Basic Configuration + +The minimal configuration: + +```js +import { defineConfig } from "@lingui/cli"; + +export default defineConfig({ + sourceLocale: "en", + locales: ["en", "es", "fr", "de"], + catalogs: [ + { + path: "/src/locales/{locale}/messages", + include: ["src"], + exclude: ["**/node_modules/**"], + }, + ], +}); +``` + +**Key fields**: +- `sourceLocale` - Your source code's language (usually "en") +- `locales` - Array of BCP-47 locale codes +- `catalogs` - Where to find/store message catalogs + +## Common Catalog Patterns + +### Pattern 1: Single Directory (Recommended for most projects) + +```js +catalogs: [ + { + path: "src/locales/{locale}/messages", + include: ["src"], + }, +] +``` + +**Structure**: +``` +src/locales/ +├── en/messages.po +├── es/messages.po +└── fr/messages.po +``` + +### Pattern 2: Separate Catalogs per Feature + +For large projects where you want to split translations: + +```js +catalogs: [ + { + path: "src/{name}/locales/{locale}", + include: ["src/{name}/"], + }, +] +``` + +**Structure**: +``` +src/ +├── auth/locales/en.po +├── dashboard/locales/en.po +└── settings/locales/en.po +``` + +Use `catalogsMergePath` to compile into single files: + +```js +catalogsMergePath: "src/locales/{locale}" +``` + +## Essential Configuration Options + +### Fallback Locales + +Handle missing translations by falling back to other locales: + +```js +fallbackLocales: { + "en-US": "en", // en-US → en + "es-MX": "es", // es-MX → es + default: "en" // Everything else → en +} +``` + +### Compilation Format + +Control the output format of compiled catalogs: + +```js +compileNamespace: "es" // ES6 modules (default: "cjs") +``` + +**Common options**: +- `cjs` - CommonJS: `module.exports = {messages: {...}}` +- `es` - ES6: `export const messages = {...}` +- `ts` - TypeScript with type definitions + +### TypeScript Support + +For TypeScript projects: + +```js +compileNamespace: "ts" +``` + +Then compile with: +```bash +lingui compile --typescript +``` + +### Parser Options + +If using TypeScript experimental decorators or Flow: + +```js +extractorParserOptions: { + tsExperimentalDecorators: true, +} +``` + +## Message Ordering + +Control catalog message order: + +```js +orderBy: "messageId" // Options: "message", "messageId", "origin" +``` + +## Common Issues & Solutions + +### Issue: Messages Not Extracted + +**Problem**: CLI doesn't find your messages. + +**Solution**: Check your `include` patterns are correct: + +```js +catalogs: [ + { + path: "src/locales/{locale}/messages", + include: ["src/**/*.{js,jsx,ts,tsx}"], // Be specific + exclude: ["**/*.test.*", "**/*.spec.*"], + }, +] +``` + +### Issue: Can't Import Compiled Catalogs + +**Problem**: Import path doesn't match compiled location. + +**Solution**: Ensure `path` matches your import: + +```js +// Config +path: "src/locales/{locale}/messages" + +// Import must match +import { messages } from "./locales/en/messages"; +``` + +## Best Practices + +1. **Use `` token** for portable paths +2. **Always exclude test files** in `exclude` patterns +3. **Set `sourceLocale`** to your development language +4. **Add `.gitignore` for compiled files**: `*.js` in locale directories diff --git a/.claude/skills/swc-plugin-compatibility/SKILL.md b/.claude/skills/swc-plugin-compatibility/SKILL.md new file mode 100644 index 0000000000..ea5150208f --- /dev/null +++ b/.claude/skills/swc-plugin-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: swc-plugin-compatibility +description: Diagnose and fix Lingui SWC plugin compatibility errors with Next.js, Rspack, or other SWC runtimes. Use when seeing errors like "failed to invoke plugin", "failed to run Wasm plugin transform", "out of bounds memory access", or "LayoutError" during builds with @lingui/swc-plugin. +--- + +# SWC Plugin Compatibility + +If you see errors like these during your build: + +``` +failed to invoke plugin on 'Some("/app/src/file.ts")' +failed to run Wasm plugin transform +RuntimeError: out of bounds memory access +LayoutError called Result::unwrap() +``` + +**This is NOT a bug.** You're using an incompatible version of `@lingui/swc-plugin` with your SWC runtime. + +## Why This Happens + +SWC plugin support is experimental. The plugin API does not follow semantic versioning. + +SWC uses Rkyv to transfer the AST between the core and plugins. Both must agree on the exact memory layout of the AST. If the layout changes (e.g., new ECMAScript features), older plugins cannot read the data correctly. + +This layout cannot be negotiated at runtime - it must match at compile time. + +## How to Fix + +### Step 1: Check the Compatibility Table + +Go to the [compatibility table](https://github.com/lingui/swc-plugin?tab=readme-ov-file#compatibility) and find the plugin version that matches your runtime. + +### Step 2: Use the Plugin Compatibility Site + +For precise matching, use https://plugins.swc.rs/: + +1. Select your runtime (e.g., `next`) +2. Select your runtime version (e.g., `next@15.0.1`) +3. Find a compatible `@lingui/swc-plugin` version + +### Step 3: Pin Your Versions + +```json +{ + "devDependencies": { + "@lingui/swc-plugin": "5.10.0" + } +} +``` + +Use an **exact version** (no `^` or `~`) to prevent accidental upgrades. + +## Version Compatibility Quick Reference + +| Plugin Version | @lingui/core | Notes | +|----------------|--------------|-------| +| `5.*` | `@lingui/core@5.*` | Current | +| `4.*` | `@lingui/core@4.*` | Legacy | + +**Important**: `@lingui/swc-plugin` does not need to match other `@lingui/*` package versions exactly. It follows its own versioning scheme. + +## Rules to Avoid Build Breakage + +1. **Pin an exact plugin version** compatible with your runtime +2. **Don't auto-bump `@lingui/swc-plugin`** - check release notes first +3. **Don't auto-bump your runtime** (Next.js, Rspack, etc.) - runtimes may bump `swc-core` in minor/patch releases +4. **Check compatibility after any upgrade** that touches SWC or the plugin + +## Understanding Runtimes + +By "runtime" we mean the tool executing SWC: Next.js, Rspack, or `@swc/core`. + +Some runtimes (like Next.js) embed SWC directly and don't use `@swc/core` from npm. This means: + +- You cannot control `swc-core` version via `package.json` +- Plugin compatibility depends on the runtime's embedded SWC version + +## Example: Next.js Configuration + +```js +// next.config.js +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + swcPlugins: [ + ['@lingui/swc-plugin', { + // Plugin options + }], + ], + }, +}; + +module.exports = nextConfig; +``` + +## Example: .swcrc Configuration + +```json +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "experimental": { + "plugins": [ + ["@lingui/swc-plugin", {}] + ] + } + } +} +``` + +## What If No Compatible Version Exists? + +If your runtime uses a newer `swc-core` that no plugin version supports yet: + +1. Check for recent plugin releases +2. Open an issue or PR at https://github.com/lingui/swc-plugin From 66399ed68579ba83bab6fbc6d33e88024183279a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:00:56 +0200 Subject: [PATCH 12/24] Ignore local aspire.config.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8c301e3688..88abc7f585 100644 --- a/.gitignore +++ b/.gitignore @@ -426,6 +426,7 @@ playwright-report/ # .NET Aspire **/.aspire/settings.json +aspire.config.json # PlatformPlatform agent workspace .workspace From 6534ceb778010819e79afe02034dc0b05a779cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:36:28 +0200 Subject: [PATCH 13/24] Centralize preferred-locale persistence in setLocale --- .../routes/-components/LoginFooterControls.tsx | 5 +---- .../routes/components/-components/PreviewAvatarMenu.tsx | 5 +---- .../components/-components/PreviewSettingsFlyouts.tsx | 5 +---- .../shared/components/BackOfficeAvatarMenu.tsx | 5 +---- .../WebApp/federated-modules/common/LocaleSwitcher.tsx | 9 ++------- .../user/preferences/-components/usePreferences.tsx | 2 -- .../infrastructure/translations/LocaleSwitcher.tsx | 5 +---- .../infrastructure/translations/Translation.tsx | 7 ++++++- .../infrastructure/translations/useInitializeLocale.ts | 5 ++--- 9 files changed, 15 insertions(+), 33 deletions(-) diff --git a/application/account/BackOffice/routes/-components/LoginFooterControls.tsx b/application/account/BackOffice/routes/-components/LoginFooterControls.tsx index 7a478f5ed7..d0d0ccb836 100644 --- a/application/account/BackOffice/routes/-components/LoginFooterControls.tsx +++ b/application/account/BackOffice/routes/-components/LoginFooterControls.tsx @@ -1,7 +1,6 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Button } from "@repo/ui/components/Button"; @@ -83,9 +82,7 @@ function LocaleDropdown() { const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; diff --git a/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx b/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx index 0c18038cd0..fb1097bc60 100644 --- a/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx +++ b/application/account/BackOffice/routes/components/-components/PreviewAvatarMenu.tsx @@ -1,7 +1,6 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Avatar, AvatarFallback } from "@repo/ui/components/Avatar"; @@ -97,9 +96,7 @@ export function PreviewAvatarMenu() { const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; diff --git a/application/account/BackOffice/routes/components/-components/PreviewSettingsFlyouts.tsx b/application/account/BackOffice/routes/components/-components/PreviewSettingsFlyouts.tsx index d5f327005c..0fed7b1315 100644 --- a/application/account/BackOffice/routes/components/-components/PreviewSettingsFlyouts.tsx +++ b/application/account/BackOffice/routes/components/-components/PreviewSettingsFlyouts.tsx @@ -1,7 +1,6 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Button } from "@repo/ui/components/Button"; import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/components/Popover"; @@ -80,9 +79,7 @@ export function PreviewLanguageFlyout() { const currentLocale = i18n.locale as Locale; const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; return ( diff --git a/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx b/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx index eb17e5e3f3..b1727d6149 100644 --- a/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx +++ b/application/account/BackOffice/shared/components/BackOfficeAvatarMenu.tsx @@ -1,7 +1,6 @@ import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Avatar, AvatarFallback } from "@repo/ui/components/Avatar"; @@ -80,9 +79,7 @@ export function BackOfficeAvatarMenu() { const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; diff --git a/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx b/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx index d088bf1a74..73161b9149 100644 --- a/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx +++ b/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx @@ -2,6 +2,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; import { enhancedFetch } from "@repo/infrastructure/http/httpClient"; +import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Button } from "@repo/ui/components/Button"; @@ -15,8 +16,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { CheckIcon, GlobeIcon } from "lucide-react"; import { use, useEffect, useState } from "react"; -const PREFERRED_LOCALE_KEY = "preferred-locale"; - const locales = Object.entries(localeMap).map(([id, info]) => ({ id: id as Locale, label: info.label @@ -50,7 +49,7 @@ export default function LocaleSwitcher({ useEffect(() => { // Get current locale from document or localStorage const htmlLang = document.documentElement.lang as Locale; - const savedLocale = localStorage.getItem(PREFERRED_LOCALE_KEY) as Locale; + const savedLocale = localStorage.getItem(preferredLocaleKey) as Locale; if (savedLocale && locales.some((l) => l.id === savedLocale)) { setCurrentLocale(savedLocale); @@ -64,15 +63,11 @@ export default function LocaleSwitcher({ // Call onAction if provided (for closing mobile menu) onAction?.(); - // Save to localStorage - localStorage.setItem(PREFERRED_LOCALE_KEY, locale); - // Only update backend if user is authenticated if (isAuthenticated) { await updateLocaleOnBackend(locale); } - document.documentElement.lang = locale; await setLocale(locale); setCurrentLocale(locale); } diff --git a/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx b/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx index e1d88519ec..f7e8431470 100644 --- a/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx +++ b/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx @@ -76,12 +76,10 @@ export function usePreferences() { const localeLabel = locales.find((l) => l.id === locale)?.label ?? locale; trackInteraction("User preferences", "interaction", `Change language to "${localeLabel}"`); - localStorage.setItem(preferredLocaleKey, locale); changeLocaleMutation.mutate( { body: { locale } }, { onSuccess: async () => { - document.documentElement.lang = locale; await setLocale(locale); setCurrentLocale(locale); } diff --git a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx b/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx index 7e378530b8..b0951fb5f9 100644 --- a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx +++ b/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx @@ -10,7 +10,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { CheckIcon, GlobeIcon } from "lucide-react"; import { use, useMemo } from "react"; -import { preferredLocaleKey } from "./constants"; import { type Locale, translationContext } from "./TranslationContext"; export function LocaleSwitcher({ "aria-label": ariaLabel }: Readonly<{ "aria-label": string }>) { @@ -28,9 +27,7 @@ export function LocaleSwitcher({ "aria-label": ariaLabel }: Readonly<{ "aria-lab const handleLocaleChange = (locale: Locale) => { if (locale !== currentLocale) { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); + setLocale(locale); } }; diff --git a/application/shared-webapp/infrastructure/translations/Translation.tsx b/application/shared-webapp/infrastructure/translations/Translation.tsx index 12a18bc5e0..6768c0bc76 100644 --- a/application/shared-webapp/infrastructure/translations/Translation.tsx +++ b/application/shared-webapp/infrastructure/translations/Translation.tsx @@ -152,12 +152,17 @@ function resolveInitialLocale(): Locale { return locales[0]; } +/** Persist the preferred-locale choice. */ +export function persistPreferredLocale(locale: Locale): void { + localStorage.setItem(preferredLocaleKey, locale); +} + /** Change the active locale for every registered system and persist the choice. */ export function setLocale(locale: string): Promise { if (!isLocale(locale)) { return Promise.resolve(); } - localStorage.setItem(preferredLocaleKey, locale); + persistPreferredLocale(locale); document.documentElement.lang = locale; return loadAllAndActivate(locale); } diff --git a/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts index 598f4bd455..0df2868976 100644 --- a/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts +++ b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts @@ -1,9 +1,8 @@ import { useContext, useEffect } from "react"; -import type { Locale } from "./Translation"; - import { AuthenticationContext } from "../auth/AuthenticationProvider"; import { preferredLocaleKey } from "./constants"; +import { type Locale, persistPreferredLocale } from "./Translation"; import { translationContext } from "./TranslationContext"; export function useInitializeLocale() { @@ -12,7 +11,7 @@ export function useInitializeLocale() { useEffect(() => { if (userInfo?.isAuthenticated) { - localStorage.setItem(preferredLocaleKey, document.documentElement.lang); + persistPreferredLocale(document.documentElement.lang as Locale); } else { const storedLocale = localStorage.getItem(preferredLocaleKey) as Locale; if (storedLocale) { From 11f5f8f930f0ac45c07cc0624cc43d0aaaad8ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:36:45 +0200 Subject: [PATCH 14/24] Clear the stored locale preference when it is the default --- .../WebApp/tests/e2e/localization-flows.spec.ts | 13 ++++++++----- .../infrastructure/translations/Translation.tsx | 12 ++++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/application/account/WebApp/tests/e2e/localization-flows.spec.ts b/application/account/WebApp/tests/e2e/localization-flows.spec.ts index b3b74b4cd4..13646d053e 100644 --- a/application/account/WebApp/tests/e2e/localization-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/localization-flows.spec.ts @@ -94,7 +94,8 @@ test.describe("@comprehensive", () => { // Verify interface updates to English await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); - await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + // en-US is the default locale, so the preference is cleared rather than stored + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBeNull(); })(); await step("Login with English interface & verify language resets to saved preference")(async () => { @@ -124,7 +125,8 @@ test.describe("@comprehensive", () => { // collisions on the substring "Preferences". await expect(page.getByRole("heading", { name: "User preferences", exact: true })).toBeVisible(); - await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + // en-US is the default locale, so the preference is cleared rather than stored + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBeNull(); })(); }); @@ -180,7 +182,8 @@ test.describe("@comprehensive", () => { await completeSignupFlow(page2, expect, user2, testContext2, true); await expect(page2.getByRole("heading", { level: 1 })).toBeVisible(); - await expect(page2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + // en-US is the default locale, so no preference is stored + await expect(page2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBeNull(); } )(); @@ -214,10 +217,10 @@ test.describe("@comprehensive", () => { await typeOneTimeCode(newPage2, getVerificationCode()); - // Verify English preference is maintained + // Verify English (default) is applied with no stored preference await expect(newPage2).toHaveURL("/dashboard"); await expect(newPage2.getByRole("heading", { level: 1 })).toBeVisible(); - await expect(newPage2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + await expect(newPage2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBeNull(); })(); }); }); diff --git a/application/shared-webapp/infrastructure/translations/Translation.tsx b/application/shared-webapp/infrastructure/translations/Translation.tsx index 6768c0bc76..61526cadb1 100644 --- a/application/shared-webapp/infrastructure/translations/Translation.tsx +++ b/application/shared-webapp/infrastructure/translations/Translation.tsx @@ -152,9 +152,17 @@ function resolveInitialLocale(): Locale { return locales[0]; } -/** Persist the preferred-locale choice. */ +/** The default locale (first in the config). Selecting it clears any stored preference, so an absent + * `preferred-locale` entry always means "use the default" -- storage never holds a redundant default. */ +export const defaultLocale = locales[0]; + +/** Persist the preferred-locale choice, clearing it when it matches the default so absence == default. */ export function persistPreferredLocale(locale: Locale): void { - localStorage.setItem(preferredLocaleKey, locale); + if (locale === defaultLocale) { + localStorage.removeItem(preferredLocaleKey); + } else { + localStorage.setItem(preferredLocaleKey, locale); + } } /** Change the active locale for every registered system and persist the choice. */ From e44bc061c45a0ac7a8c9d5f48bffe18db711429d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:44:20 +0200 Subject: [PATCH 15/24] Apply the stored locale before first render for anonymous users --- .../translations/Translation.tsx | 28 ++++++++----------- .../infrastructure/translations/constants.ts | 15 ++++++++++ .../translations/useInitializeLocale.ts | 7 +++-- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/application/shared-webapp/infrastructure/translations/Translation.tsx b/application/shared-webapp/infrastructure/translations/Translation.tsx index 61526cadb1..fae843e694 100644 --- a/application/shared-webapp/infrastructure/translations/Translation.tsx +++ b/application/shared-webapp/infrastructure/translations/Translation.tsx @@ -4,7 +4,7 @@ import { i18n, type Messages } from "@lingui/core"; import { I18nProvider, useLingui } from "@lingui/react"; import { type ComponentType, Suspense, useEffect, useMemo, useState } from "react"; -import { preferredLocaleKey } from "./constants"; +import { persistPreferredLocale, preferredLocaleKey } from "./constants"; import localeMap from "./i18n.config"; import { type TranslationContext as TranslationContextValue, translationContext } from "./TranslationContext"; @@ -138,11 +138,20 @@ function ensureSystemActive(systemId: string, loader: LocalLoaderFunction, local } /** - * Resolve the initial locale. Authenticated users follow the server (``, set from the JWT); - * anonymous users have their stored choice re-applied by `useInitializeLocale` after mount. + * Resolve the initial locale before the first render. Authenticated users follow the server-rendered + * `` (set from their JWT/DB locale) -- it is authoritative. Anonymous users, whose stored + * preference the server cannot know, have that choice applied here so the first paint is already in the + * right locale rather than flashing the default until `useInitializeLocale` runs post-mount. */ function resolveInitialLocale(): Locale { const serverLocale = document.documentElement.lang; + if (import.meta.user_info_env.isAuthenticated && isLocale(serverLocale)) { + return serverLocale; + } + const storedLocale = localStorage.getItem(preferredLocaleKey); + if (storedLocale && isLocale(storedLocale)) { + return storedLocale; + } if (isLocale(serverLocale)) { return serverLocale; } @@ -152,19 +161,6 @@ function resolveInitialLocale(): Locale { return locales[0]; } -/** The default locale (first in the config). Selecting it clears any stored preference, so an absent - * `preferred-locale` entry always means "use the default" -- storage never holds a redundant default. */ -export const defaultLocale = locales[0]; - -/** Persist the preferred-locale choice, clearing it when it matches the default so absence == default. */ -export function persistPreferredLocale(locale: Locale): void { - if (locale === defaultLocale) { - localStorage.removeItem(preferredLocaleKey); - } else { - localStorage.setItem(preferredLocaleKey, locale); - } -} - /** Change the active locale for every registered system and persist the choice. */ export function setLocale(locale: string): Promise { if (!isLocale(locale)) { diff --git a/application/shared-webapp/infrastructure/translations/constants.ts b/application/shared-webapp/infrastructure/translations/constants.ts index eefee14bb0..101c410b1f 100644 --- a/application/shared-webapp/infrastructure/translations/constants.ts +++ b/application/shared-webapp/infrastructure/translations/constants.ts @@ -1 +1,16 @@ +import localeMap from "./i18n.config"; + export const preferredLocaleKey = "preferred-locale"; + +// The default locale (first in the config). Selecting it clears any stored preference, so an absent +// `preferred-locale` entry always means "use the default" -- storage never holds a redundant default. +const defaultLocale = Object.keys(localeMap)[0]; + +/** Persist the preferred-locale choice, clearing it when it matches the default so absence == default. */ +export function persistPreferredLocale(locale: string): void { + if (locale === defaultLocale) { + localStorage.removeItem(preferredLocaleKey); + } else { + localStorage.setItem(preferredLocaleKey, locale); + } +} diff --git a/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts index 0df2868976..a23332835b 100644 --- a/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts +++ b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts @@ -1,8 +1,9 @@ import { useContext, useEffect } from "react"; +import type { Locale } from "./Translation"; + import { AuthenticationContext } from "../auth/AuthenticationProvider"; -import { preferredLocaleKey } from "./constants"; -import { type Locale, persistPreferredLocale } from "./Translation"; +import { persistPreferredLocale, preferredLocaleKey } from "./constants"; import { translationContext } from "./TranslationContext"; export function useInitializeLocale() { @@ -11,7 +12,7 @@ export function useInitializeLocale() { useEffect(() => { if (userInfo?.isAuthenticated) { - persistPreferredLocale(document.documentElement.lang as Locale); + persistPreferredLocale(document.documentElement.lang); } else { const storedLocale = localStorage.getItem(preferredLocaleKey) as Locale; if (storedLocale) { From 18bcf9c63fdd7e700e1e8e5146aa92daa25e4e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:39:28 +0200 Subject: [PATCH 16/24] Always persist the explicit locale choice --- .../WebApp/tests/e2e/localization-flows.spec.ts | 13 +++++-------- .../infrastructure/translations/constants.ts | 16 ++++------------ 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/application/account/WebApp/tests/e2e/localization-flows.spec.ts b/application/account/WebApp/tests/e2e/localization-flows.spec.ts index 13646d053e..b3b74b4cd4 100644 --- a/application/account/WebApp/tests/e2e/localization-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/localization-flows.spec.ts @@ -94,8 +94,7 @@ test.describe("@comprehensive", () => { // Verify interface updates to English await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); - // en-US is the default locale, so the preference is cleared rather than stored - await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBeNull(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); })(); await step("Login with English interface & verify language resets to saved preference")(async () => { @@ -125,8 +124,7 @@ test.describe("@comprehensive", () => { // collisions on the substring "Preferences". await expect(page.getByRole("heading", { name: "User preferences", exact: true })).toBeVisible(); - // en-US is the default locale, so the preference is cleared rather than stored - await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBeNull(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); })(); }); @@ -182,8 +180,7 @@ test.describe("@comprehensive", () => { await completeSignupFlow(page2, expect, user2, testContext2, true); await expect(page2.getByRole("heading", { level: 1 })).toBeVisible(); - // en-US is the default locale, so no preference is stored - await expect(page2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBeNull(); + await expect(page2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); } )(); @@ -217,10 +214,10 @@ test.describe("@comprehensive", () => { await typeOneTimeCode(newPage2, getVerificationCode()); - // Verify English (default) is applied with no stored preference + // Verify English preference is maintained await expect(newPage2).toHaveURL("/dashboard"); await expect(newPage2.getByRole("heading", { level: 1 })).toBeVisible(); - await expect(newPage2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBeNull(); + await expect(newPage2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); })(); }); }); diff --git a/application/shared-webapp/infrastructure/translations/constants.ts b/application/shared-webapp/infrastructure/translations/constants.ts index 101c410b1f..0830366de3 100644 --- a/application/shared-webapp/infrastructure/translations/constants.ts +++ b/application/shared-webapp/infrastructure/translations/constants.ts @@ -1,16 +1,8 @@ -import localeMap from "./i18n.config"; - export const preferredLocaleKey = "preferred-locale"; -// The default locale (first in the config). Selecting it clears any stored preference, so an absent -// `preferred-locale` entry always means "use the default" -- storage never holds a redundant default. -const defaultLocale = Object.keys(localeMap)[0]; - -/** Persist the preferred-locale choice, clearing it when it matches the default so absence == default. */ +/** Persist the user's explicit locale choice so it survives reloads. Always store it -- an anonymous + * user's server-rendered default is derived from Accept-Language (`UserInfo.GetValidLocale(browserLocale)`), + * so it is not necessarily the config default; clearing "the default" would silently drop a real choice. */ export function persistPreferredLocale(locale: string): void { - if (locale === defaultLocale) { - localStorage.removeItem(preferredLocaleKey); - } else { - localStorage.setItem(preferredLocaleKey, locale); - } + localStorage.setItem(preferredLocaleKey, locale); } From 8e5b2948029ce06989f1d663fc173fceba84fa49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:39:29 +0200 Subject: [PATCH 17/24] Harden translation catalog loading against switch races, load failures, and nondeterministic precedence --- .../translations/Translation.tsx | 104 +------------- .../translations/catalogStore.ts | 135 ++++++++++++++++++ 2 files changed, 137 insertions(+), 102 deletions(-) create mode 100644 application/shared-webapp/infrastructure/translations/catalogStore.ts diff --git a/application/shared-webapp/infrastructure/translations/Translation.tsx b/application/shared-webapp/infrastructure/translations/Translation.tsx index fae843e694..67b3723cfc 100644 --- a/application/shared-webapp/infrastructure/translations/Translation.tsx +++ b/application/shared-webapp/infrastructure/translations/Translation.tsx @@ -4,6 +4,7 @@ import { i18n, type Messages } from "@lingui/core"; import { I18nProvider, useLingui } from "@lingui/react"; import { type ComponentType, Suspense, useEffect, useMemo, useState } from "react"; +import { ensureSystemActive, hasFailed, isLoaded, loadAllAndActivate, registerCatalog } from "./catalogStore"; import { persistPreferredLocale, preferredLocaleKey } from "./constants"; import localeMap from "./i18n.config"; import { type TranslationContext as TranslationContextValue, translationContext } from "./TranslationContext"; @@ -37,106 +38,6 @@ export function isLocale(value: string): value is Locale { return value in localeMap; } -/** - * The single point of coordination for translations across all self-contained systems. - * - * Only the global Lingui `i18n` object (shared as a module-federation singleton) and `globalThis` - * are guaranteed to be single instances across remotes -- `@repo/*` packages are compiled per-remote, - * so a module-level store would be duplicated. Every system registers a loader for its own catalog and - * all catalogs merge into the one shared `i18n` dictionary. - */ -type TranslationStore = { - activeLocale: Locale | null; - loaders: Map; - merged: Map; - systemLoaded: Map>; - loadInflight: Map>; - activateInflight: Map>; -}; - -const store: TranslationStore = ((globalThis as Record).__appTranslation ??= { - activeLocale: null, - loaders: new Map(), - merged: new Map(), - systemLoaded: new Map(), - loadInflight: new Map(), - activateInflight: new Map() -} satisfies TranslationStore) as TranslationStore; - -export function registerCatalog(systemId: string, loader: LocalLoaderFunction): void { - if (!store.loaders.has(systemId)) { - store.loaders.set(systemId, loader); - } -} - -function markLoaded(systemId: string, locale: Locale): void { - let set = store.systemLoaded.get(systemId); - if (!set) { - set = new Set(); - store.systemLoaded.set(systemId, set); - } - set.add(locale); -} - -function isLoaded(systemId: string, locale: Locale): boolean { - return store.systemLoaded.get(systemId)?.has(locale) ?? false; -} - -/** Load a system's catalog for a locale into the merged dictionary (no activation). Idempotent. */ -function loadSystemLocale(systemId: string, loader: LocalLoaderFunction, locale: Locale): Promise { - if (isLoaded(systemId, locale)) { - return Promise.resolve(); - } - const key = `${systemId}:${locale}`; - const existing = store.loadInflight.get(key); - if (existing) { - return existing; - } - const promise = loader(locale) - .then(({ messages }) => { - // Later merges win; the shared-ui loader is registered first so it stays lowest precedence, the host - // next, and federated remotes register on mount so they layer on top -- matching the previous - // "shared < own < remote" precedence. - store.merged.set(locale, { ...store.merged.get(locale), ...messages }); - }) - .catch((error) => { - // A single system's catalog failing to load must not reject: that would crash the Suspense boundary - // in `SystemTranslationGate`, or abort a locale switch (`loadAllAndActivate`) for every other system. - // Degrade to untranslated for just this system instead. - console.error(`Failed to load translations for "${systemId}" (${locale})`, error); - }) - .finally(() => { - // Mark loaded on success and on failure alike, so a persistent load error degrades gracefully to - // untranslated rather than re-suspending forever. - markLoaded(systemId, locale); - }); - store.loadInflight.set(key, promise); - return promise; -} - -/** Load every registered system's catalog for the locale, then activate the merged dictionary once. */ -async function loadAllAndActivate(locale: Locale): Promise { - await Promise.all([...store.loaders].map(([systemId, loader]) => loadSystemLocale(systemId, loader, locale))); - store.activeLocale = locale; - i18n.loadAndActivate({ locale, messages: store.merged.get(locale) ?? {} }); -} - -/** Merge a single system's catalog into the currently active locale (used when it mounts late). */ -function ensureSystemActive(systemId: string, loader: LocalLoaderFunction, locale: Locale): Promise { - const key = `${systemId}:${locale}`; - const existing = store.activateInflight.get(key); - if (existing) { - return existing; - } - const promise = loadSystemLocale(systemId, loader, locale).then(() => { - if (store.activeLocale === locale) { - i18n.loadAndActivate({ locale, messages: store.merged.get(locale) ?? {} }); - } - }); - store.activateInflight.set(key, promise); - return promise; -} - /** * Resolve the initial locale before the first render. Authenticated users follow the server-rendered * `` (set from their JWT/DB locale) -- it is authoritative. Anonymous users, whose stored @@ -167,7 +68,6 @@ export function setLocale(locale: string): Promise { return Promise.resolve(); } persistPreferredLocale(locale); - document.documentElement.lang = locale; return loadAllAndActivate(locale); } @@ -232,7 +132,7 @@ function SystemTranslationGate({ }) { const { i18n: activeI18n } = useLingui(); const locale = activeI18n.locale as Locale; - if (!isLoaded(systemId, locale)) { + if (!isLoaded(systemId, locale) && !hasFailed(systemId, locale)) { throw ensureSystemActive(systemId, loader, locale); } return <>{children}; diff --git a/application/shared-webapp/infrastructure/translations/catalogStore.ts b/application/shared-webapp/infrastructure/translations/catalogStore.ts new file mode 100644 index 0000000000..035af39a7d --- /dev/null +++ b/application/shared-webapp/infrastructure/translations/catalogStore.ts @@ -0,0 +1,135 @@ +import { i18n, type Messages } from "@lingui/core"; + +import type { Locale, LocalLoaderFunction } from "./Translation"; + +/** + * The single point of coordination for translations across all self-contained systems. + * + * Only the global Lingui `i18n` object (shared as a module-federation singleton) and `globalThis` are + * guaranteed to be single instances across remotes -- `@repo/*` packages are compiled per-remote, so a + * module-level store would be duplicated. Every system registers a loader for its own catalog; catalogs + * merge into one shared dictionary in registration order and activate on the shared `i18n`. + */ +type TranslationStore = { + activeLocale: Locale | null; + requestedLocale: Locale | null; + loaders: Map; + systemMessages: Map; + merged: Map; + systemLoaded: Map>; + failed: Set; + loadInflight: Map>; + activateInflight: Map>; +}; + +const store: TranslationStore = ((globalThis as Record).__appTranslation ??= { + activeLocale: null, + requestedLocale: null, + loaders: new Map(), + systemMessages: new Map(), + merged: new Map(), + systemLoaded: new Map(), + failed: new Set(), + loadInflight: new Map(), + activateInflight: new Map() +} satisfies TranslationStore) as TranslationStore; + +export function registerCatalog(systemId: string, loader: LocalLoaderFunction): void { + if (!store.loaders.has(systemId)) { + store.loaders.set(systemId, loader); + } +} + +export function isLoaded(systemId: string, locale: Locale): boolean { + return store.systemLoaded.get(systemId)?.has(locale) ?? false; +} + +/** A prior load attempt failed; the Suspense gate renders untranslated instead of re-suspending forever. */ +export function hasFailed(systemId: string, locale: Locale): boolean { + return store.failed.has(`${systemId}:${locale}`); +} + +function markLoaded(systemId: string, locale: Locale): void { + let set = store.systemLoaded.get(systemId); + if (!set) { + set = new Set(); + store.systemLoaded.set(systemId, set); + } + set.add(locale); +} + +/** + * Rebuild the merged dictionary for a locale in loader-registration order (shared-ui < host < remotes), + * so precedence is deterministic regardless of which dynamic import happens to resolve first. + */ +function rebuildMerged(locale: Locale): Messages { + const merged: Messages = {}; + for (const systemId of store.loaders.keys()) { + const messages = store.systemMessages.get(`${systemId}:${locale}`); + if (messages) { + Object.assign(merged, messages); + } + } + store.merged.set(locale, merged); + return merged; +} + +/** Load a system's catalog for a locale (no activation). Idempotent; a failed load stays retryable. */ +function loadSystemLocale(systemId: string, loader: LocalLoaderFunction, locale: Locale): Promise { + if (isLoaded(systemId, locale)) { + return Promise.resolve(); + } + const key = `${systemId}:${locale}`; + const existing = store.loadInflight.get(key); + if (existing) { + return existing; + } + const promise = loader(locale) + .then(({ messages }) => { + store.systemMessages.set(key, messages); + store.failed.delete(key); + markLoaded(systemId, locale); + }) + .catch((error) => { + // A single system's catalog failing must not reject (that would crash the Suspense boundary or abort a + // locale switch for every other system). Record the failure so the gate degrades to untranslated, but + // do NOT mark it loaded -- the next locale activation retries it once the inflight entry is cleared. + console.error(`Failed to load translations for "${systemId}" (${locale})`, error); + store.failed.add(key); + }) + .finally(() => { + store.loadInflight.delete(key); + }); + store.loadInflight.set(key, promise); + return promise; +} + +/** Load every registered system's catalog for the locale, then activate the merged dictionary once. */ +export async function loadAllAndActivate(locale: Locale): Promise { + store.requestedLocale = locale; + await Promise.all([...store.loaders].map(([systemId, loader]) => loadSystemLocale(systemId, loader, locale))); + // A newer switch superseded this one while awaiting; let it win so a slow earlier load can't clobber it. + if (store.requestedLocale !== locale) { + return; + } + store.activeLocale = locale; + document.documentElement.lang = locale; + i18n.loadAndActivate({ locale, messages: rebuildMerged(locale) }); +} + +/** Merge a single system's catalog into the currently active locale (used when it mounts late). */ +export function ensureSystemActive(systemId: string, loader: LocalLoaderFunction, locale: Locale): Promise { + const key = `${systemId}:${locale}`; + const existing = store.activateInflight.get(key); + if (existing) { + return existing; + } + const promise = loadSystemLocale(systemId, loader, locale).then(() => { + store.activateInflight.delete(key); + if (store.activeLocale === locale) { + i18n.loadAndActivate({ locale, messages: rebuildMerged(locale) }); + } + }); + store.activateInflight.set(key, promise); + return promise; +} From ebe32226960f92e33b250fd0f4c73a9db3a1d40e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:39:29 +0200 Subject: [PATCH 18/24] Do not gate host content on the account remote's translations --- .../federated-modules/subscription/TenantStateGuard.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx b/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx index 336c1617dc..95f15e21c9 100644 --- a/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx +++ b/application/account/WebApp/federated-modules/subscription/TenantStateGuard.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from "react"; import { api, TenantState } from "@/shared/lib/api/client"; -import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; import SuspendedPage from "./SuspendedPage"; @@ -22,4 +21,7 @@ function TenantStateGuard({ children, pathname }: Readonly). Wrapping it would gate host content behind the account remote's +// catalog load. The one account UI it can render, , self-wraps its own translations. +export default TenantStateGuard; From 178497e4929dd1a04cc545288f28dc3cce660a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:39:29 +0200 Subject: [PATCH 19/24] Validate and de-duplicate anonymous locale re-application --- .../translations/useInitializeLocale.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts index a23332835b..984e91c630 100644 --- a/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts +++ b/application/shared-webapp/infrastructure/translations/useInitializeLocale.ts @@ -1,9 +1,9 @@ +import { i18n } from "@lingui/core"; import { useContext, useEffect } from "react"; -import type { Locale } from "./Translation"; - import { AuthenticationContext } from "../auth/AuthenticationProvider"; import { persistPreferredLocale, preferredLocaleKey } from "./constants"; +import { isLocale } from "./Translation"; import { translationContext } from "./TranslationContext"; export function useInitializeLocale() { @@ -14,9 +14,11 @@ export function useInitializeLocale() { if (userInfo?.isAuthenticated) { persistPreferredLocale(document.documentElement.lang); } else { - const storedLocale = localStorage.getItem(preferredLocaleKey) as Locale; - if (storedLocale) { - document.documentElement.lang = storedLocale; + // Anonymous: re-apply a stored preference only if it is a supported locale and not already active. + // resolveInitialLocale already applies it before first paint; this covers an in-session transition + // to anonymous (e.g. logout without a full reload). setLocale validates and sets . + const storedLocale = localStorage.getItem(preferredLocaleKey); + if (storedLocale && isLocale(storedLocale) && storedLocale !== i18n.locale) { setLocale(storedLocale); } } From 478080ee2b5da65192b76911848b2b53731a02bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:39:29 +0200 Subject: [PATCH 20/24] Derive the active locale from the i18n singleton in the account language switcher --- .../common/LocaleSwitcher.tsx | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx b/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx index 73161b9149..c24f5503ba 100644 --- a/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx +++ b/application/account/WebApp/federated-modules/common/LocaleSwitcher.tsx @@ -1,8 +1,8 @@ import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; import { enhancedFetch } from "@repo/infrastructure/http/httpClient"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { Button } from "@repo/ui/components/Button"; @@ -14,7 +14,7 @@ import { } from "@repo/ui/components/DropdownMenu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; import { CheckIcon, GlobeIcon } from "lucide-react"; -import { use, useEffect, useState } from "react"; +import { use } from "react"; const locales = Object.entries(localeMap).map(([id, info]) => ({ id: id as Locale, @@ -42,22 +42,12 @@ export default function LocaleSwitcher({ variant?: "icon" | "mobile-menu"; onAction?: () => void; } = {}) { - const [currentLocale, setCurrentLocale] = useState("en-US"); + // Derive the active locale from the shared i18n singleton so the checkmark stays correct no matter which + // path changed the locale (this switcher, the preferences screen, an auth refresh); no local mirror. + const currentLocale = useLingui().i18n.locale as Locale; const isAuthenticated = useIsAuthenticated(); const { setLocale } = use(translationContext); - useEffect(() => { - // Get current locale from document or localStorage - const htmlLang = document.documentElement.lang as Locale; - const savedLocale = localStorage.getItem(preferredLocaleKey) as Locale; - - if (savedLocale && locales.some((l) => l.id === savedLocale)) { - setCurrentLocale(savedLocale); - } else if (htmlLang && locales.some((l) => l.id === htmlLang)) { - setCurrentLocale(htmlLang); - } - }, []); - const handleLocaleChange = async (locale: Locale) => { if (locale !== currentLocale) { // Call onAction if provided (for closing mobile menu) @@ -69,7 +59,6 @@ export default function LocaleSwitcher({ } await setLocale(locale); - setCurrentLocale(locale); } }; From 702991755dc78cb0e7adafff0df9a2428ca710e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:39:29 +0200 Subject: [PATCH 21/24] Remove dead federated translation exposes --- application/account/WebApp/rsbuild.config.ts | 4 +--- .../build/module-federation-types/account.d.ts | 8 -------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/application/account/WebApp/rsbuild.config.ts b/application/account/WebApp/rsbuild.config.ts index 71d4a8591d..b4450365f3 100644 --- a/application/account/WebApp/rsbuild.config.ts +++ b/application/account/WebApp/rsbuild.config.ts @@ -61,9 +61,7 @@ export default defineConfig({ "./PublicNavigation": "./federated-modules/public/PublicNavigation.tsx", "./SuspendedPage": "./federated-modules/subscription/SuspendedPage.tsx", "./TenantStateGuard": "./federated-modules/subscription/TenantStateGuard.tsx", - "./UserMenu": "./federated-modules/userMenu/UserMenu.tsx", - "./translations/da-DK": "./shared/translations/locale/da-DK.ts", - "./translations/en-US": "./shared/translations/locale/en-US.ts" + "./UserMenu": "./federated-modules/userMenu/UserMenu.tsx" } }) ] diff --git a/application/shared-webapp/build/module-federation-types/account.d.ts b/application/shared-webapp/build/module-federation-types/account.d.ts index 02b16129f5..662c637265 100644 --- a/application/shared-webapp/build/module-federation-types/account.d.ts +++ b/application/shared-webapp/build/module-federation-types/account.d.ts @@ -36,11 +36,3 @@ declare module "account/TenantStateGuard" { declare module "account/UserMenu" { export default ReactNode; } -declare module "account/translations/da-DK" { - import type { Messages } from "@lingui/core"; - export const messages: Messages; -} -declare module "account/translations/en-US" { - import type { Messages } from "@lingui/core"; - export const messages: Messages; -} From 43360ce0134046cff9b4f2b98faf30f8bce3175a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:44:28 +0200 Subject: [PATCH 22/24] Derive the active locale from the i18n singleton in user preferences --- .../preferences/-components/usePreferences.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx b/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx index f7e8431470..71fae9b08f 100644 --- a/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx +++ b/application/account/WebApp/routes/user/preferences/-components/usePreferences.tsx @@ -1,6 +1,6 @@ import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; import { trackInteraction } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; -import { preferredLocaleKey } from "@repo/infrastructure/translations/constants"; import localeMap from "@repo/infrastructure/translations/i18n.config"; import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; import { MoonStarIcon, SunMoonIcon } from "lucide-react"; @@ -34,7 +34,9 @@ export const ThemeMode = { export function usePreferences() { const { theme, setTheme, resolvedTheme } = useTheme(); - const [currentLocale, setCurrentLocale] = useState("en-US"); + // Active locale comes from the shared i18n singleton so the radio stays correct regardless of how the + // locale changed (this screen, the language switcher, an auth refresh); no local mirror to keep in sync. + const currentLocale = useLingui().i18n.locale as Locale; const [currentZoomLevel, setCurrentZoomLevel] = useState(defaultZoomValue); const { setLocale } = use(translationContext); @@ -54,15 +56,6 @@ export function usePreferences() { }); useEffect(() => { - const htmlLang = document.documentElement.lang as Locale; - const savedLocale = localStorage.getItem(preferredLocaleKey) as Locale; - - if (savedLocale && locales.some((l) => l.id === savedLocale)) { - setCurrentLocale(savedLocale); - } else if (htmlLang && locales.some((l) => l.id === htmlLang)) { - setCurrentLocale(htmlLang); - } - const savedZoomLevel = localStorage.getItem(zoomLevelStorageKey); if (savedZoomLevel && validZoomValues.includes(savedZoomLevel)) { setCurrentZoomLevel(savedZoomLevel); @@ -81,7 +74,6 @@ export function usePreferences() { { onSuccess: async () => { await setLocale(locale); - setCurrentLocale(locale); } } ); From 921385c730724246e19dc6341aa78c2358acee53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20N=2EO=2E=20N=C3=B8rgaard=20Henriksen?= <1136718+raix@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:04:30 +0200 Subject: [PATCH 23/24] Remove dead translation-expose branch from federation type generation --- .../shared-webapp/build/plugin/ModuleFederationPlugin.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts index e051689f52..f798387b3d 100644 --- a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts +++ b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts @@ -124,14 +124,7 @@ function generateModuleFederationTypesFolder(system: string, exposes: Record { logger.info(`[Module Federation] Expose: ${exportPath} => ${importPath}`); - // Pattern matching for different module types - // Translation files follow the pattern: ./translations/xx-XX (e.g., ./translations/en-US) - const translationPattern = /^\.\/translations\/[a-z]{2}-[A-Z]{2}$/; - if (translationPattern.test(exportPath)) { - return `declare module "${exportPath.replace(/^\./, system)}" {\n import type { Messages } from "@lingui/core";\n export const messages: Messages;\n}`; - } - - // Default to ReactNode export for components + // Every exposed federated module is a React component. return `declare module "${exportPath.replace(/^\./, system)}" {\n export default ReactNode;\n}`; }) .join("\n"); From ff22078353ba7a17dd2d4d78ddff6e128afd5b37 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 6 Jul 2026 11:29:59 +0200 Subject: [PATCH 24/24] Restore environment type-checking and clean up rslib library configs --- application/shared-webapp/build/package.json | 2 +- application/shared-webapp/infrastructure/package.json | 3 +-- application/shared-webapp/infrastructure/rslib.config.ts | 2 +- application/shared-webapp/ui/package.json | 3 +-- application/shared-webapp/ui/rslib.config.ts | 5 +++++ 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/application/shared-webapp/build/package.json b/application/shared-webapp/build/package.json index 69ee298f2c..38bfba46c6 100644 --- a/application/shared-webapp/build/package.json +++ b/application/shared-webapp/build/package.json @@ -16,7 +16,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "rslib build", + "build": "rslib build && tsc --noEmit -p environment/tsconfig.json", "check": "oxfmt --check && oxlint", "dev": "rslib build --watch --no-clean", "dev:setup": "rslib build", diff --git a/application/shared-webapp/infrastructure/package.json b/application/shared-webapp/infrastructure/package.json index 4b6bfe2e02..89a42c1bdd 100644 --- a/application/shared-webapp/infrastructure/package.json +++ b/application/shared-webapp/infrastructure/package.json @@ -10,8 +10,7 @@ "exports": { "./*": { "types": "./dist/*.d.ts", - "import": "./dist/*.js", - "require": "./dist/*.js" + "import": "./dist/*.js" }, "./package.json": "./package.json" }, diff --git a/application/shared-webapp/infrastructure/rslib.config.ts b/application/shared-webapp/infrastructure/rslib.config.ts index 00404f5f17..868cb93a2f 100644 --- a/application/shared-webapp/infrastructure/rslib.config.ts +++ b/application/shared-webapp/infrastructure/rslib.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from "@rslib/core"; export default defineConfig({ source: { entry: { - index: ["./**/*.tsx", "./**/*.ts", "./**/*.json", "!rslib.config.ts", "!node_modules/**", "!dist/**"] + index: ["./**/*.tsx", "./**/*.ts", "!rslib.config.ts", "!node_modules/**", "!dist/**"] } }, lib: [ diff --git a/application/shared-webapp/ui/package.json b/application/shared-webapp/ui/package.json index 05259e04ad..394dde90cf 100644 --- a/application/shared-webapp/ui/package.json +++ b/application/shared-webapp/ui/package.json @@ -14,8 +14,7 @@ "./*.png": "./dist/*.png", "./*": { "types": "./dist/*.d.ts", - "import": "./dist/*.js", - "require": "./dist/*.js" + "import": "./dist/*.js" }, "./theme.css": "./theme.css", "./tailwind.css": "./tailwind.css", diff --git a/application/shared-webapp/ui/rslib.config.ts b/application/shared-webapp/ui/rslib.config.ts index 8101ece015..c8f0b2ba6d 100644 --- a/application/shared-webapp/ui/rslib.config.ts +++ b/application/shared-webapp/ui/rslib.config.ts @@ -13,6 +13,11 @@ export default defineConfig({ "./**/*.css", "./**/*.png", "./**/*.webp", + // The root Tailwind entry stylesheets are consumed directly via the package root exports + // (`./theme.css`, `./tailwind.css`), not from `dist`; keep them out of the entry so rslib does + // not emit dead, shadowed copies into `dist`. + "!theme.css", + "!tailwind.css", "!rslib.config.ts", "!node_modules/**", "!dist/**"