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
This commit is contained in:
Leonardo Benini
2025-10-23 21:16:43 +02:00
committed by GitHub
parent 012e30801e
commit 226984cf0f
25 changed files with 314 additions and 9 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -2,3 +2,4 @@ export * from "./extensions/custom-fields"
export * from "./extensions/routes"
export * from "./extensions/widgets"
export * from "./virtual-modules"
export * from "./utils"

View File

@@ -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)
}

View File

@@ -0,0 +1 @@
export * from "./deep-merge"

View File

@@ -0,0 +1,3 @@
export function isObject(obj: any): obj is object {
return obj != null && obj?.constructor?.name === "Object"
}

View File

@@ -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

View File

@@ -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)
)
})
})

View File

@@ -0,0 +1,15 @@
import fs from "fs/promises"
import { generateHash } from "../utils"
import { getI18nIndexFilesFromSources } from "./helpers"
export async function generateI18nHash(sources: Set<string>): Promise<string> {
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)
}

View File

@@ -0,0 +1,33 @@
import { outdent } from "outdent"
import { normalizePath } from "../utils"
import { getI18nIndexFilesFromSources } from "./helpers"
export async function generateI18n(sources: Set<string>) {
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,
}
}

View File

@@ -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<string>
): Promise<string[]> {
return (await Promise.all(
Array.from(sources).map(async (source) =>
crawl(`${source}/i18n`, "index", { min: 0, max: 0 })
)
)).flat()
}

View File

@@ -0,0 +1,3 @@
export { generateI18nHash } from "./generate-i18n-hash"
export { getI18nIndexFilesFromSources } from "./helpers"
export { generateI18n } from "./generate-i18n"

View File

@@ -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<string, ModuleConfig> = {
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",
},
],
},
]

View File

@@ -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]

View File

@@ -0,0 +1,27 @@
import outdent from "outdent"
import { generateModule } from "../utils"
import { generateI18n } from "../i18n"
export async function generateVirtualI18nModule(
sources: Set<string>,
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)
}

View File

@@ -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"

View File

@@ -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 = {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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<CustomFieldContainerZone, React.ComponentType<{ data: any }>[]> {
@@ -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),
}
}

View File

@@ -60,6 +60,8 @@ export type ConfigExtension = {
fields: Record<string, ConfigFieldExtension>
}
export type I18nExtension = Record<string, Record<string, any>>
export type LinkModule = {
links: Record<CustomFieldModel, (string | string[])[]>
}
@@ -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
}

View File

@@ -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),
}
}

View File

@@ -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
}

View File

@@ -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,