From 226984cf0f229bec00ee33a3a1a981b57889c11a Mon Sep 17 00:00:00 2001 From: Leonardo Benini Date: Thu, 23 Oct 2025 21:16:43 +0200 Subject: [PATCH] feat(admin-*,dashboard): add dashboard i18n extensions (#13763) * virtual i18n module * changeset * fallback ns fallback to the default "translation" ns if the key isnt found. Allows to use a single "useTranslation("customNs")" hook for both custom and medusa-provided keys * simplify merges * optional for backward compat * fix HMR * fix generated deepMerge * test --- .changeset/beige-shirts-work.md | 8 ++ .../admin-bundler/src/commands/plugin.ts | 1 + .../admin/admin-bundler/src/utils/config.ts | 1 + packages/admin/admin-shared/src/index.ts | 1 + .../admin-shared/src/utils/deep-merge.ts | 26 +++++ .../admin/admin-shared/src/utils/index.ts | 1 + .../admin/admin-shared/src/utils/is-object.ts | 3 + .../src/virtual-modules/constants.ts | 2 + .../src/i18n/__tests__/generate-i18n.spec.ts | 94 +++++++++++++++++++ .../src/i18n/generate-i18n-hash.ts | 15 +++ .../src/i18n/generate-i18n.ts | 33 +++++++ .../admin-vite-plugin/src/i18n/helpers.ts | 15 +++ .../admin/admin-vite-plugin/src/i18n/index.ts | 3 + .../admin/admin-vite-plugin/src/plugin.ts | 25 ++++- packages/admin/admin-vite-plugin/src/utils.ts | 4 +- .../generate-virtual-i18n-module.ts | 27 ++++++ .../src/virtual-modules/index.ts | 1 + packages/admin/admin-vite-plugin/src/vmod.ts | 8 ++ packages/admin/dashboard/src/app.tsx | 2 + .../src/components/utilities/i18n/i18n.tsx | 10 +- .../src/dashboard-app/dashboard-app.tsx | 22 +++++ .../dashboard/src/dashboard-app/types.ts | 7 ++ packages/admin/dashboard/src/i18n/config.ts | 7 +- packages/admin/dashboard/src/module.d.ts | 6 ++ packages/admin/dashboard/tsup.config.cjs | 1 + 25 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 .changeset/beige-shirts-work.md create mode 100644 packages/admin/admin-shared/src/utils/deep-merge.ts create mode 100644 packages/admin/admin-shared/src/utils/index.ts create mode 100644 packages/admin/admin-shared/src/utils/is-object.ts create mode 100644 packages/admin/admin-vite-plugin/src/i18n/__tests__/generate-i18n.spec.ts create mode 100644 packages/admin/admin-vite-plugin/src/i18n/generate-i18n-hash.ts create mode 100644 packages/admin/admin-vite-plugin/src/i18n/generate-i18n.ts create mode 100644 packages/admin/admin-vite-plugin/src/i18n/helpers.ts create mode 100644 packages/admin/admin-vite-plugin/src/i18n/index.ts create mode 100644 packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-i18n-module.ts diff --git a/.changeset/beige-shirts-work.md b/.changeset/beige-shirts-work.md new file mode 100644 index 0000000000..8af85cc3b3 --- /dev/null +++ b/.changeset/beige-shirts-work.md @@ -0,0 +1,8 @@ +--- +"@medusajs/admin-vite-plugin": patch +"@medusajs/admin-bundler": patch +"@medusajs/admin-shared": patch +"@medusajs/dashboard": patch +--- + +feat(admin-\*,dashboard): add dashboard i18n extensions diff --git a/packages/admin/admin-bundler/src/commands/plugin.ts b/packages/admin/admin-bundler/src/commands/plugin.ts index c9e35afe53..e2c56d777e 100644 --- a/packages/admin/admin-bundler/src/commands/plugin.ts +++ b/packages/admin/admin-bundler/src/commands/plugin.ts @@ -24,6 +24,7 @@ export async function plugin(options: PluginOptions) { "react", "react/jsx-runtime", "react-router-dom", + "react-i18next", "@medusajs/js-sdk", "@medusajs/admin-sdk", "@tanstack/react-query", diff --git a/packages/admin/admin-bundler/src/utils/config.ts b/packages/admin/admin-bundler/src/utils/config.ts index 10166f0b80..2c45b0cb55 100644 --- a/packages/admin/admin-bundler/src/utils/config.ts +++ b/packages/admin/admin-bundler/src/utils/config.ts @@ -36,6 +36,7 @@ export async function getViteConfig( "react/jsx-runtime", "react-dom/client", "react-router-dom", + "react-i18next", "@medusajs/ui", "@medusajs/dashboard", "@medusajs/js-sdk", diff --git a/packages/admin/admin-shared/src/index.ts b/packages/admin/admin-shared/src/index.ts index 676ccd4cf0..04c69a44ee 100644 --- a/packages/admin/admin-shared/src/index.ts +++ b/packages/admin/admin-shared/src/index.ts @@ -2,3 +2,4 @@ export * from "./extensions/custom-fields" export * from "./extensions/routes" export * from "./extensions/widgets" export * from "./virtual-modules" +export * from "./utils" diff --git a/packages/admin/admin-shared/src/utils/deep-merge.ts b/packages/admin/admin-shared/src/utils/deep-merge.ts new file mode 100644 index 0000000000..699416a608 --- /dev/null +++ b/packages/admin/admin-shared/src/utils/deep-merge.ts @@ -0,0 +1,26 @@ +import { isObject } from "./is-object" + +export function deepMerge(target: any, source: any) { + const recursive = (a:any, b:any) => { + if (!isObject(a)) { + return b + } + if (!isObject(b)) { + return a + } + + Object.keys(b).forEach((key) => { + if (isObject((b as any)[key])) { + (a as any)[key] ??= {}; + (a as any)[key] = deepMerge((a as any)[key], (b as any)[key]) + } else { + (a as any)[key] = (b as any)[key] + } + }) + + return a + } + + const copy = { ...target } + return recursive(copy, source) +} diff --git a/packages/admin/admin-shared/src/utils/index.ts b/packages/admin/admin-shared/src/utils/index.ts new file mode 100644 index 0000000000..b825d27eef --- /dev/null +++ b/packages/admin/admin-shared/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./deep-merge" diff --git a/packages/admin/admin-shared/src/utils/is-object.ts b/packages/admin/admin-shared/src/utils/is-object.ts new file mode 100644 index 0000000000..e577671adc --- /dev/null +++ b/packages/admin/admin-shared/src/utils/is-object.ts @@ -0,0 +1,3 @@ +export function isObject(obj: any): obj is object { + return obj != null && obj?.constructor?.name === "Object" +} diff --git a/packages/admin/admin-shared/src/virtual-modules/constants.ts b/packages/admin/admin-shared/src/virtual-modules/constants.ts index 6cf49975db..c70a5d74f7 100644 --- a/packages/admin/admin-shared/src/virtual-modules/constants.ts +++ b/packages/admin/admin-shared/src/virtual-modules/constants.ts @@ -4,6 +4,7 @@ export const DISPLAY_VIRTUAL_MODULE = `virtual:medusa/displays` export const ROUTE_VIRTUAL_MODULE = `virtual:medusa/routes` export const MENU_ITEM_VIRTUAL_MODULE = `virtual:medusa/menu-items` export const WIDGET_VIRTUAL_MODULE = `virtual:medusa/widgets` +export const I18N_VIRTUAL_MODULE = `virtual:medusa/i18n` export const VIRTUAL_MODULES = [ LINK_VIRTUAL_MODULE, @@ -12,4 +13,5 @@ export const VIRTUAL_MODULES = [ ROUTE_VIRTUAL_MODULE, MENU_ITEM_VIRTUAL_MODULE, WIDGET_VIRTUAL_MODULE, + I18N_VIRTUAL_MODULE, ] as const diff --git a/packages/admin/admin-vite-plugin/src/i18n/__tests__/generate-i18n.spec.ts b/packages/admin/admin-vite-plugin/src/i18n/__tests__/generate-i18n.spec.ts new file mode 100644 index 0000000000..f230abb734 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/i18n/__tests__/generate-i18n.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest" + +import * as utils from "../../utils" +import { generateI18n } from "../generate-i18n" + +// Mock the dependencies +vi.mock("../../utils", async () => { + const actual = await vi.importActual("../../utils") + return { + ...actual, + crawl: vi.fn(), + } +}) + +const expectedI18nSingleSource = ` + resources: i18nTranslations0 +` + +const expectedI18nMultipleSources = ` + resources: deepMerge(deepMerge(i18nTranslations0, i18nTranslations1), i18nTranslations2) +` + +const expectedI18nNoSources = ` + resources: {} +` + +describe("generateI18n", () => { + it("should generate i18n with single source", async () => { + const mockFiles = ["Users/user/medusa/src/admin/i18n/index.ts"] + vi.mocked(utils.crawl).mockResolvedValue(mockFiles) + + const result = await generateI18n( + new Set(["Users/user/medusa/src/admin"]) + ) + + expect(result.imports).toEqual([ + `import i18nTranslations0 from "Users/user/medusa/src/admin/i18n/index.ts"`, + ]) + expect(utils.normalizeString(result.code)).toEqual( + utils.normalizeString(expectedI18nSingleSource) + ) + }) + + it("should handle windows paths", async () => { + const mockFiles = ["C:\\medusa\\src\\admin\\i18n\\index.ts"] + vi.mocked(utils.crawl).mockResolvedValue(mockFiles) + + const result = await generateI18n(new Set(["C:\\medusa\\src\\admin"])) + + expect(result.imports).toEqual([ + `import i18nTranslations0 from "C:/medusa/src/admin/i18n/index.ts"`, + ]) + expect(utils.normalizeString(result.code)).toEqual( + utils.normalizeString(expectedI18nSingleSource) + ) + }) + + it("should generate i18n with multiple sources", async () => { + vi.mocked(utils.crawl) + .mockResolvedValueOnce(["Users/user/medusa/src/admin/i18n/index.ts"]) + .mockResolvedValueOnce(["Users/user/medusa/src/plugin1/i18n/index.ts"]) + .mockResolvedValueOnce(["Users/user/medusa/src/plugin2/i18n/index.ts"]) + + const result = await generateI18n( + new Set([ + "Users/user/medusa/src/admin", + "Users/user/medusa/src/plugin1", + "Users/user/medusa/src/plugin2", + ]) + ) + + expect(result.imports).toEqual([ + `import i18nTranslations0 from "Users/user/medusa/src/admin/i18n/index.ts"`, + `import i18nTranslations1 from "Users/user/medusa/src/plugin1/i18n/index.ts"`, + `import i18nTranslations2 from "Users/user/medusa/src/plugin2/i18n/index.ts"`, + ]) + expect(utils.normalizeString(result.code)).toEqual( + utils.normalizeString(expectedI18nMultipleSources) + ) + }) + + it("should handle no i18n sources", async () => { + vi.mocked(utils.crawl).mockResolvedValue([]) + + const result = await generateI18n( + new Set(["Users/user/medusa/src/admin"]) + ) + + expect(result.imports).toEqual([]) + expect(utils.normalizeString(result.code)).toEqual( + utils.normalizeString(expectedI18nNoSources) + ) + }) +}) diff --git a/packages/admin/admin-vite-plugin/src/i18n/generate-i18n-hash.ts b/packages/admin/admin-vite-plugin/src/i18n/generate-i18n-hash.ts new file mode 100644 index 0000000000..77b390cf3d --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/i18n/generate-i18n-hash.ts @@ -0,0 +1,15 @@ +import fs from "fs/promises" +import { generateHash } from "../utils" +import { getI18nIndexFilesFromSources } from "./helpers" + +export async function generateI18nHash(sources: Set): Promise { + const indexFiles = await getI18nIndexFilesFromSources(sources) + + const contents = await Promise.all( + indexFiles.map(file => fs.readFile(file, "utf-8")) + ) + + const totalContent = contents.join("") + + return generateHash(totalContent) +} diff --git a/packages/admin/admin-vite-plugin/src/i18n/generate-i18n.ts b/packages/admin/admin-vite-plugin/src/i18n/generate-i18n.ts new file mode 100644 index 0000000000..501230c1df --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/i18n/generate-i18n.ts @@ -0,0 +1,33 @@ +import { outdent } from "outdent" +import { normalizePath } from "../utils" +import { getI18nIndexFilesFromSources } from "./helpers" + +export async function generateI18n(sources: Set) { + const indexFiles = await getI18nIndexFilesFromSources(sources) + + const imports = indexFiles.map((file, index) => { + const normalizedPath = normalizePath(file) + return `import i18nTranslations${index} from "${normalizedPath}"` + }) + + let mergeCode = '{}' + if (indexFiles.length === 1) { + mergeCode = 'i18nTranslations0' + } else if (indexFiles.length > 1) { + // Only happens in dev mode if there are 2+ plugins linked with plugin:develop + // Chain deepMerge calls since it only accepts 2 arguments + mergeCode = indexFiles.slice(1).reduce((acc, _, index) => { + return `deepMerge(${acc}, i18nTranslations${index + 1})` + }, 'i18nTranslations0') + } + + const code = outdent` + resources: ${mergeCode} + ` + + return { + imports, + code, + } +} + diff --git a/packages/admin/admin-vite-plugin/src/i18n/helpers.ts b/packages/admin/admin-vite-plugin/src/i18n/helpers.ts new file mode 100644 index 0000000000..983acb293f --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/i18n/helpers.ts @@ -0,0 +1,15 @@ +import { crawl } from "../utils" + +/** + * Get i18n index files from sources + * Looks for src/admin/i18n/index.ts in each source + */ +export async function getI18nIndexFilesFromSources( + sources: Set +): Promise { + return (await Promise.all( + Array.from(sources).map(async (source) => + crawl(`${source}/i18n`, "index", { min: 0, max: 0 }) + ) + )).flat() +} diff --git a/packages/admin/admin-vite-plugin/src/i18n/index.ts b/packages/admin/admin-vite-plugin/src/i18n/index.ts new file mode 100644 index 0000000000..d1ca0416a7 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/i18n/index.ts @@ -0,0 +1,3 @@ +export { generateI18nHash } from "./generate-i18n-hash" +export { getI18nIndexFilesFromSources } from "./helpers" +export { generateI18n } from "./generate-i18n" diff --git a/packages/admin/admin-vite-plugin/src/plugin.ts b/packages/admin/admin-vite-plugin/src/plugin.ts index c19d828e98..bfec2e377c 100644 --- a/packages/admin/admin-vite-plugin/src/plugin.ts +++ b/packages/admin/admin-vite-plugin/src/plugin.ts @@ -3,12 +3,14 @@ import { rm, writeFile } from "node:fs/promises" import path from "node:path" import type * as Vite from "vite" import { generateCustomFieldHashes } from "./custom-fields" +import { generateI18nHash } from "./i18n" import { generateRouteHashes } from "./routes" import { MedusaVitePlugin } from "./types" import { AdminSubdirectory, isFileInAdminSubdirectory } from "./utils" import { generateVirtualDisplayModule, generateVirtualFormModule, + generateVirtualI18nModule, generateVirtualLinkModule, generateVirtualMenuItemModule, generateVirtualRouteModule, @@ -84,6 +86,7 @@ export const medusaVitePlugin: MedusaVitePlugin = (options) => { const menuItemModule = await generateVirtualMenuItemModule(sources, true) const formModule = await generateVirtualFormModule(sources, true) const displayModule = await generateVirtualDisplayModule(sources, true) + const i18nModule = await generateVirtualI18nModule(sources, true) // Create the index.js content that re-exports everything return ` @@ -93,13 +96,15 @@ export const medusaVitePlugin: MedusaVitePlugin = (options) => { ${menuItemModule.code} ${formModule.code} ${displayModule.code} + ${i18nModule.code} const plugin = { widgetModule, routeModule, menuItemModule, formModule, - displayModule + displayModule, + i18nModule } export default plugin @@ -229,6 +234,11 @@ const loadConfigs: Record = { moduleGenerator: async (sources) => generateVirtualMenuItemModule(sources), hashKey: vmod.virtual.menuItem, }, + [vmod.resolved.i18n]: { + hashGenerator: async (sources) => generateI18nHash(sources), + moduleGenerator: async (sources) => generateVirtualI18nModule(sources), + hashKey: vmod.virtual.i18n, + }, } type WatcherConfig = { @@ -292,4 +302,17 @@ const watcherConfigs: WatcherConfig[] = [ }, ], }, + { + subdirectory: "i18n", + hashGenerator: async (sources) => ({ + i18nHash: await generateI18nHash(sources), + }), + modules: [ + { + virtualModule: vmod.virtual.i18n, + resolvedModule: vmod.resolved.i18n, + hashKey: "i18nHash", + }, + ], + }, ] diff --git a/packages/admin/admin-vite-plugin/src/utils.ts b/packages/admin/admin-vite-plugin/src/utils.ts index 6758cd6871..aa40457699 100644 --- a/packages/admin/admin-vite-plugin/src/utils.ts +++ b/packages/admin/admin-vite-plugin/src/utils.ts @@ -49,7 +49,7 @@ export function generateModule(code: string) { } } -export const VALID_FILE_EXTENSIONS = [".tsx", ".jsx", ".js"] +export const VALID_FILE_EXTENSIONS = [".tsx", ".jsx", ".js", ".ts"] /** * Crawls a directory and returns all files that match the criteria. @@ -176,7 +176,7 @@ export function generateHash(content: string) { return crypto.createHash("md5").update(content).digest("hex") } -const ADMIN_SUBDIRECTORIES = ["routes", "custom-fields", "widgets"] as const +const ADMIN_SUBDIRECTORIES = ["routes", "custom-fields", "widgets", "i18n"] as const export type AdminSubdirectory = (typeof ADMIN_SUBDIRECTORIES)[number] diff --git a/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-i18n-module.ts b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-i18n-module.ts new file mode 100644 index 0000000000..1b3f495857 --- /dev/null +++ b/packages/admin/admin-vite-plugin/src/virtual-modules/generate-virtual-i18n-module.ts @@ -0,0 +1,27 @@ +import outdent from "outdent" +import { generateModule } from "../utils" +import { generateI18n } from "../i18n" + +export async function generateVirtualI18nModule( + sources: Set, + pluginMode = false +) { + const i18n = await generateI18n(sources) + + const imports = [ + 'import { deepMerge } from "@medusajs/admin-shared"', + ...i18n.imports + ] + + const code = outdent` + ${imports.join("\n")} + + ${pluginMode + ? `const i18nModule = { ${i18n.code} }` + : `export default { ${i18n.code} }` + } + ` + + return generateModule(code) +} + diff --git a/packages/admin/admin-vite-plugin/src/virtual-modules/index.ts b/packages/admin/admin-vite-plugin/src/virtual-modules/index.ts index b18b9589fe..517633c678 100644 --- a/packages/admin/admin-vite-plugin/src/virtual-modules/index.ts +++ b/packages/admin/admin-vite-plugin/src/virtual-modules/index.ts @@ -4,3 +4,4 @@ export * from "./generate-virtual-link-module" export * from "./generate-virtual-menu-item-module" export * from "./generate-virtual-route-module" export * from "./generate-virtual-widget-module" +export * from "./generate-virtual-i18n-module" diff --git a/packages/admin/admin-vite-plugin/src/vmod.ts b/packages/admin/admin-vite-plugin/src/vmod.ts index 355552431f..e2a45fba0b 100644 --- a/packages/admin/admin-vite-plugin/src/vmod.ts +++ b/packages/admin/admin-vite-plugin/src/vmod.ts @@ -1,6 +1,7 @@ import { DISPLAY_VIRTUAL_MODULE, FORM_VIRTUAL_MODULE, + I18N_VIRTUAL_MODULE, LINK_VIRTUAL_MODULE, MENU_ITEM_VIRTUAL_MODULE, ROUTE_VIRTUAL_MODULE, @@ -13,6 +14,7 @@ const RESOLVED_DISPLAY_VIRTUAL_MODULE = `\0${DISPLAY_VIRTUAL_MODULE}` const RESOLVED_ROUTE_VIRTUAL_MODULE = `\0${ROUTE_VIRTUAL_MODULE}` const RESOLVED_MENU_ITEM_VIRTUAL_MODULE = `\0${MENU_ITEM_VIRTUAL_MODULE}` const RESOLVED_WIDGET_VIRTUAL_MODULE = `\0${WIDGET_VIRTUAL_MODULE}` +const RESOLVED_I18N_VIRTUAL_MODULE = `\0${I18N_VIRTUAL_MODULE}` const VIRTUAL_MODULES = [ LINK_VIRTUAL_MODULE, @@ -21,6 +23,7 @@ const VIRTUAL_MODULES = [ ROUTE_VIRTUAL_MODULE, MENU_ITEM_VIRTUAL_MODULE, WIDGET_VIRTUAL_MODULE, + I18N_VIRTUAL_MODULE, ] as const const RESOLVED_VIRTUAL_MODULES = [ @@ -30,6 +33,7 @@ const RESOLVED_VIRTUAL_MODULES = [ RESOLVED_ROUTE_VIRTUAL_MODULE, RESOLVED_MENU_ITEM_VIRTUAL_MODULE, RESOLVED_WIDGET_VIRTUAL_MODULE, + RESOLVED_I18N_VIRTUAL_MODULE, ] as const export function resolveVirtualId(id: string) { @@ -55,6 +59,8 @@ export type VirtualModule = | typeof ROUTE_VIRTUAL_MODULE | typeof MENU_ITEM_VIRTUAL_MODULE | typeof WIDGET_VIRTUAL_MODULE + | typeof I18N_VIRTUAL_MODULE + const resolvedVirtualModuleIds = { link: RESOLVED_LINK_VIRTUAL_MODULE, form: RESOLVED_FORM_VIRTUAL_MODULE, @@ -62,6 +68,7 @@ const resolvedVirtualModuleIds = { route: RESOLVED_ROUTE_VIRTUAL_MODULE, menuItem: RESOLVED_MENU_ITEM_VIRTUAL_MODULE, widget: RESOLVED_WIDGET_VIRTUAL_MODULE, + i18n: RESOLVED_I18N_VIRTUAL_MODULE, } as const const virtualModuleIds = { @@ -71,6 +78,7 @@ const virtualModuleIds = { route: ROUTE_VIRTUAL_MODULE, menuItem: MENU_ITEM_VIRTUAL_MODULE, widget: WIDGET_VIRTUAL_MODULE, + i18n: I18N_VIRTUAL_MODULE, } as const export const vmod = { diff --git a/packages/admin/dashboard/src/app.tsx b/packages/admin/dashboard/src/app.tsx index 7f46055724..870d7aa212 100644 --- a/packages/admin/dashboard/src/app.tsx +++ b/packages/admin/dashboard/src/app.tsx @@ -3,6 +3,7 @@ import { DashboardPlugin } from "./dashboard-app/types" import displayModule from "virtual:medusa/displays" import formModule from "virtual:medusa/forms" +import i18nModule from "virtual:medusa/i18n" import menuItemModule from "virtual:medusa/menu-items" import routeModule from "virtual:medusa/routes" import widgetModule from "virtual:medusa/widgets" @@ -15,6 +16,7 @@ const localPlugin = { displayModule, formModule, menuItemModule, + i18nModule, } interface AppProps { diff --git a/packages/admin/dashboard/src/components/utilities/i18n/i18n.tsx b/packages/admin/dashboard/src/components/utilities/i18n/i18n.tsx index 7ac31af6b6..e9e533f44f 100644 --- a/packages/admin/dashboard/src/components/utilities/i18n/i18n.tsx +++ b/packages/admin/dashboard/src/components/utilities/i18n/i18n.tsx @@ -3,12 +3,16 @@ import LanguageDetector from "i18next-browser-languagedetector" import { initReactI18next } from "react-i18next" import { defaultI18nOptions } from "../../../i18n/config" +import { useExtension } from "../../../providers/extension-provider" export const I18n = () => { + const { getI18nResources } = useExtension() + if (i18n.isInitialized) { return null } + const resources = getI18nResources() i18n .use( new LanguageDetector(null, { @@ -17,7 +21,11 @@ export const I18n = () => { }) ) .use(initReactI18next) - .init(defaultI18nOptions) + .init({ + ...defaultI18nOptions, + resources, + supportedLngs: Object.keys(resources), + }) return null } diff --git a/packages/admin/dashboard/src/dashboard-app/dashboard-app.tsx b/packages/admin/dashboard/src/dashboard-app/dashboard-app.tsx index 2e2f23b3f1..294ae330c0 100644 --- a/packages/admin/dashboard/src/dashboard-app/dashboard-app.tsx +++ b/packages/admin/dashboard/src/dashboard-app/dashboard-app.tsx @@ -3,6 +3,7 @@ import { CustomFieldFormTab, CustomFieldFormZone, CustomFieldModel, + deepMerge, InjectionZone, NESTED_ROUTE_POSITIONS, } from "@medusajs/admin-shared" @@ -14,6 +15,7 @@ import { } from "react-router-dom" import { INavItem } from "../components/layout/nav-item" import { Providers } from "../providers" +import coreTranslations from "../i18n/translations" import { getRouteMap } from "./routes/get-route.map" import { createRouteMap, getRouteExtensions } from "./routes/utils" import { @@ -28,6 +30,7 @@ import { FormFieldExtension, FormFieldMap, FormZoneMap, + I18nExtension, MenuItemExtension, MenuItemKey, MenuMap, @@ -47,6 +50,7 @@ export class DashboardApp { private displays: DisplayMap private coreRoutes: RouteObject[] private settingsRoutes: RouteObject[] + private i18nResources: I18nExtension constructor({ plugins }: DashboardAppProps) { this.widgets = this.populateWidgets(plugins) @@ -60,6 +64,7 @@ export class DashboardApp { this.fields = fields this.configs = configs this.displays = this.populateDisplays(plugins) + this.i18nResources = this.populateI18n(plugins) } private populateRoutes(plugins: DashboardPlugin[]) { @@ -378,6 +383,18 @@ export class DashboardApp { return displays } + private populateI18n( + plugins: DashboardPlugin[] + ): I18nExtension { + let resources: I18nExtension = { ...coreTranslations } + + for (const plugin of plugins) { + resources = deepMerge(resources, plugin.i18nModule?.resources) + } + + return resources + } + private processDisplays( displays: DisplayExtension[] ): Map[]> { @@ -431,6 +448,10 @@ export class DashboardApp { return this.displays.get(model)?.get(zone) || [] } + private getI18nResources() { + return this.i18nResources + } + get api() { return { getMenu: this.getMenu.bind(this), @@ -438,6 +459,7 @@ export class DashboardApp { getFormFields: this.getFormFields.bind(this), getFormConfigs: this.getFormConfigs.bind(this), getDisplays: this.getDisplays.bind(this), + getI18nResources: this.getI18nResources.bind(this), } } diff --git a/packages/admin/dashboard/src/dashboard-app/types.ts b/packages/admin/dashboard/src/dashboard-app/types.ts index 7b3470f66e..48cf34a34b 100644 --- a/packages/admin/dashboard/src/dashboard-app/types.ts +++ b/packages/admin/dashboard/src/dashboard-app/types.ts @@ -60,6 +60,8 @@ export type ConfigExtension = { fields: Record } +export type I18nExtension = Record> + export type LinkModule = { links: Record } @@ -90,6 +92,10 @@ export type MenuItemModule = { menuItems: MenuItemExtension[] } +export type I18nModule = { + resources: I18nExtension +} + export type MenuItemKey = "coreExtensions" | "settingsExtensions" export type FormField = FormFieldExtension & { @@ -131,4 +137,5 @@ export type DashboardPlugin = { menuItemModule: MenuItemModule widgetModule: WidgetModule routeModule: RouteModule + i18nModule?: I18nModule } diff --git a/packages/admin/dashboard/src/i18n/config.ts b/packages/admin/dashboard/src/i18n/config.ts index 966cbfaafd..39368bbfd1 100644 --- a/packages/admin/dashboard/src/i18n/config.ts +++ b/packages/admin/dashboard/src/i18n/config.ts @@ -1,7 +1,5 @@ import { InitOptions } from "i18next" -import translations from "./translations" - export const defaultI18nOptions: InitOptions = { debug: process.env.NODE_ENV === "development", detection: { @@ -11,9 +9,8 @@ export const defaultI18nOptions: InitOptions = { order: ["cookie", "localStorage", "header"], }, fallbackLng: "en", + fallbackNS: "translation", interpolation: { escapeValue: false, - }, - resources: translations, - supportedLngs: Object.keys(translations), + } } diff --git a/packages/admin/dashboard/src/module.d.ts b/packages/admin/dashboard/src/module.d.ts index 6f8294635a..f14c668664 100644 --- a/packages/admin/dashboard/src/module.d.ts +++ b/packages/admin/dashboard/src/module.d.ts @@ -33,3 +33,9 @@ declare module "virtual:medusa/widgets" { const widgetModule: WidgetModule export default widgetModule } + +declare module "virtual:medusa/i18n" { + import type { I18nModule } from "./extensions" + const i18nModule: I18nModule + export default i18nModule +} diff --git a/packages/admin/dashboard/tsup.config.cjs b/packages/admin/dashboard/tsup.config.cjs index 2ac53f06d4..01c805fb66 100644 --- a/packages/admin/dashboard/tsup.config.cjs +++ b/packages/admin/dashboard/tsup.config.cjs @@ -10,6 +10,7 @@ export default defineConfig({ "virtual:medusa/links", "virtual:medusa/menu-items", "virtual:medusa/widgets", + "virtual:medusa/i18n", ], tsconfig: "tsconfig.build.json", clean: true,